Greenback 4 rokov pred
rodič
commit
7986465cf7
34 zmenil súbory, kde vykonal 1678 pridanie a 707 odobranie
  1. 61 189
      Emby.Server.Implementations/ApplicationHost.cs
  2. 7 1
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  3. BIN
      Emby.Server.Implementations/Plugins/Active.png
  4. BIN
      Emby.Server.Implementations/Plugins/Malfunction.png
  5. BIN
      Emby.Server.Implementations/Plugins/NotSupported.png
  6. 674 0
      Emby.Server.Implementations/Plugins/PluginManager.cs
  7. 0 60
      Emby.Server.Implementations/Plugins/PluginManifest.cs
  8. BIN
      Emby.Server.Implementations/Plugins/RestartRequired.png
  9. BIN
      Emby.Server.Implementations/Plugins/Superceded.png
  10. BIN
      Emby.Server.Implementations/Plugins/blank.png
  11. BIN
      Emby.Server.Implementations/Plugins/disabled.png
  12. 1 1
      Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
  13. 248 208
      Emby.Server.Implementations/Updates/InstallationManager.cs
  14. 12 8
      Jellyfin.Api/Controllers/DashboardController.cs
  15. 7 1
      Jellyfin.Api/Controllers/PackageController.cs
  16. 220 68
      Jellyfin.Api/Controllers/PluginsController.cs
  17. 4 4
      Jellyfin.Api/Models/ConfigurationPageInfo.cs
  18. 28 14
      MediaBrowser.Common/IApplicationHost.cs
  19. 6 9
      MediaBrowser.Common/Plugins/BasePlugin.cs
  20. 33 0
      MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs
  21. 3 37
      MediaBrowser.Common/Plugins/IPlugin.cs
  22. 86 0
      MediaBrowser.Common/Plugins/IPluginManager.cs
  23. 61 33
      MediaBrowser.Common/Plugins/LocalPlugin.cs
  24. 85 0
      MediaBrowser.Common/Plugins/PluginManifest.cs
  25. 20 12
      MediaBrowser.Common/Updates/IInstallationManager.cs
  26. 9 2
      MediaBrowser.Common/Updates/InstallationEventArgs.cs
  27. 4 3
      MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs
  28. 0 10
      MediaBrowser.Controller/IServerApplicationHost.cs
  29. 10 0
      MediaBrowser.Model/Configuration/ServerConfiguration.cs
  30. 29 10
      MediaBrowser.Model/Plugins/PluginInfo.cs
  31. 17 0
      MediaBrowser.Model/Plugins/PluginStatus.cs
  32. 29 14
      MediaBrowser.Model/Updates/PackageInfo.cs
  33. 21 21
      MediaBrowser.Model/Updates/VersionInfo.cs
  34. 3 2
      MediaBrowser.sln

+ 61 - 189
Emby.Server.Implementations/ApplicationHost.cs

@@ -34,7 +34,6 @@ using Emby.Server.Implementations.LiveTv;
 using Emby.Server.Implementations.Localization;
 using Emby.Server.Implementations.Net;
 using Emby.Server.Implementations.Playlists;
-using Emby.Server.Implementations.Plugins;
 using Emby.Server.Implementations.QuickConnect;
 using Emby.Server.Implementations.ScheduledTasks;
 using Emby.Server.Implementations.Security;
@@ -119,7 +118,9 @@ namespace Emby.Server.Implementations
         private readonly IXmlSerializer _xmlSerializer;
         private readonly IJsonSerializer _jsonSerializer;
         private readonly IStartupOptions _startupOptions;
+        private readonly IPluginManager _pluginManager;
 
+        private List<Type> _creatingInstances;
         private IMediaEncoder _mediaEncoder;
         private ISessionManager _sessionManager;
         private string[] _urlPrefixes;
@@ -181,16 +182,6 @@ namespace Emby.Server.Implementations
 
         protected IServiceCollection ServiceCollection { get; }
 
-        private IPlugin[] _plugins;
-
-        private IReadOnlyList<LocalPlugin> _pluginsManifests;
-
-        /// <summary>
-        /// Gets the plugins.
-        /// </summary>
-        /// <value>The plugins.</value>
-        public IReadOnlyList<IPlugin> Plugins => _plugins;
-
         /// <summary>
         /// Gets the logger factory.
         /// </summary>
@@ -294,6 +285,14 @@ namespace Emby.Server.Implementations
             ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version;
             ApplicationVersionString = ApplicationVersion.ToString(3);
             ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
+
+            _pluginManager = new PluginManager(
+                LoggerFactory,
+                this,
+                ServerConfigurationManager.Configuration,
+                ApplicationPaths.PluginsPath,
+                ApplicationPaths.CachePath,
+                ApplicationVersion);
         }
 
         /// <summary>
@@ -393,8 +392,26 @@ namespace Emby.Server.Implementations
         /// <returns>System.Object.</returns>
         protected object CreateInstanceSafe(Type type)
         {
+            if (_creatingInstances == null)
+            {
+                _creatingInstances = new List<Type>();
+            }
+
+            if (_creatingInstances.IndexOf(type) != -1)
+            {
+                Logger.LogError("DI Loop detected.");
+                Logger.LogError("Attempted creation of {Type}", type.FullName);
+                foreach (var entry in _creatingInstances)
+                {
+                    Logger.LogError("Called from: {stack}", entry.FullName);
+                }
+
+                throw new ExternalException("DI Loop detected.");
+            }
+
             try
             {
+                _creatingInstances.Add(type);
                 Logger.LogDebug("Creating instance of {Type}", type);
                 return ActivatorUtilities.CreateInstance(ServiceProvider, type);
             }
@@ -403,6 +420,10 @@ namespace Emby.Server.Implementations
                 Logger.LogError(ex, "Error creating {Type}", type);
                 return null;
             }
+            finally
+            {
+                _creatingInstances.Remove(type);
+            }
         }
 
         /// <summary>
@@ -412,11 +433,7 @@ namespace Emby.Server.Implementations
         /// <returns>``0.</returns>
         public T Resolve<T>() => ServiceProvider.GetService<T>();
 
-        /// <summary>
-        /// Gets the export types.
-        /// </summary>
-        /// <typeparam name="T">The type.</typeparam>
-        /// <returns>IEnumerable{Type}.</returns>
+        /// <inheritdoc/>
         public IEnumerable<Type> GetExportTypes<T>()
         {
             var currentType = typeof(T);
@@ -445,6 +462,27 @@ namespace Emby.Server.Implementations
             return parts;
         }
 
+        /// <inheritdoc />
+        public IReadOnlyCollection<T> GetExports<T>(CreationDelegate defaultFunc, bool manageLifetime = true)
+        {
+            // Convert to list so this isn't executed for each iteration
+            var parts = GetExportTypes<T>()
+                .Select(i => defaultFunc(i))
+                .Where(i => i != null)
+                .Cast<T>()
+                .ToList();
+
+            if (manageLifetime)
+            {
+                lock (_disposableParts)
+                {
+                    _disposableParts.AddRange(parts.OfType<IDisposable>());
+                }
+            }
+
+            return parts;
+        }
+
         /// <summary>
         /// Runs the startup tasks.
         /// </summary>
@@ -509,7 +547,7 @@ namespace Emby.Server.Implementations
 
             RegisterServices();
 
-            RegisterPluginServices();
+            _pluginManager.RegisterServices(ServiceCollection);
         }
 
         /// <summary>
@@ -523,7 +561,7 @@ namespace Emby.Server.Implementations
 
             ServiceCollection.AddSingleton(ConfigurationManager);
             ServiceCollection.AddSingleton<IApplicationHost>(this);
-
+            ServiceCollection.AddSingleton<IPluginManager>(_pluginManager);
             ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
 
             ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>();
@@ -768,34 +806,7 @@ namespace Emby.Server.Implementations
             }
 
             ConfigurationManager.AddParts(GetExports<IConfigurationFactory>());
-            _plugins = GetExports<IPlugin>()
-                        .Where(i => i != null)
-                        .ToArray();
-
-            if (Plugins != null)
-            {
-                foreach (var plugin in Plugins)
-                {
-                    if (_pluginsManifests != null && plugin is IPluginAssembly assemblyPlugin)
-                    {
-                        // Ensure the version number matches the Plugin Manifest information.
-                        foreach (var item in _pluginsManifests)
-                        {
-                            if (Path.GetDirectoryName(plugin.AssemblyFilePath).Equals(item.Path, StringComparison.OrdinalIgnoreCase))
-                            {
-                                // Update version number to that of the manifest.
-                                assemblyPlugin.SetAttributes(
-                                    plugin.AssemblyFilePath,
-                                    Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(plugin.AssemblyFilePath)),
-                                    item.Version);
-                                break;
-                            }
-                        }
-                    }
-
-                    Logger.LogInformation("Loaded plugin: {PluginName} {PluginVersion}", plugin.Name, plugin.Version);
-                }
-            }
+            _pluginManager.CreatePlugins();
 
             _urlPrefixes = GetUrlPrefixes().ToArray();
 
@@ -834,22 +845,6 @@ namespace Emby.Server.Implementations
             _allConcreteTypes = GetTypes(GetComposablePartAssemblies()).ToArray();
         }
 
-        private void RegisterPluginServices()
-        {
-            foreach (var pluginServiceRegistrator in GetExportTypes<IPluginServiceRegistrator>())
-            {
-                try
-                {
-                    var instance = (IPluginServiceRegistrator)Activator.CreateInstance(pluginServiceRegistrator);
-                    instance.RegisterServices(ServiceCollection);
-                }
-                catch (Exception ex)
-                {
-                    Logger.LogError(ex, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator.Assembly);
-                }
-            }
-        }
-
         private IEnumerable<Type> GetTypes(IEnumerable<Assembly> assemblies)
         {
             foreach (var ass in assemblies)
@@ -862,11 +857,13 @@ namespace Emby.Server.Implementations
                 catch (FileNotFoundException ex)
                 {
                     Logger.LogError(ex, "Error getting exported types from {Assembly}", ass.FullName);
+                    _pluginManager.FailPlugin(ass);
                     continue;
                 }
                 catch (TypeLoadException ex)
                 {
                     Logger.LogError(ex, "Error loading types from {Assembly}.", ass.FullName);
+                    _pluginManager.FailPlugin(ass);
                     continue;
                 }
 
@@ -1005,129 +1002,15 @@ namespace Emby.Server.Implementations
 
         protected abstract void RestartInternal();
 
-        /// <inheritdoc/>
-        public IEnumerable<LocalPlugin> GetLocalPlugins(string path, bool cleanup = true)
-        {
-            var minimumVersion = new Version(0, 0, 0, 1);
-            var versions = new List<LocalPlugin>();
-            if (!Directory.Exists(path))
-            {
-                // Plugin path doesn't exist, don't try to enumerate subfolders.
-                return Enumerable.Empty<LocalPlugin>();
-            }
-
-            var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly);
-
-            foreach (var dir in directories)
-            {
-                try
-                {
-                    var metafile = Path.Combine(dir, "meta.json");
-                    if (File.Exists(metafile))
-                    {
-                        var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile);
-
-                        if (!Version.TryParse(manifest.TargetAbi, out var targetAbi))
-                        {
-                            targetAbi = minimumVersion;
-                        }
-
-                        if (!Version.TryParse(manifest.Version, out var version))
-                        {
-                            version = minimumVersion;
-                        }
-
-                        if (ApplicationVersion >= targetAbi)
-                        {
-                            // Only load Plugins if the plugin is built for this version or below.
-                            versions.Add(new LocalPlugin(manifest.Guid, manifest.Name, version, dir));
-                        }
-                    }
-                    else
-                    {
-                        // No metafile, so lets see if the folder is versioned.
-                        metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1];
-
-                        int versionIndex = dir.LastIndexOf('_');
-                        if (versionIndex != -1 && Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version parsedVersion))
-                        {
-                            // Versioned folder.
-                            versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir));
-                        }
-                        else
-                        {
-                            // Un-versioned folder - Add it under the path name and version 0.0.0.1.
-                            versions.Add(new LocalPlugin(Guid.Empty, metafile, minimumVersion, dir));
-                        }
-                    }
-                }
-                catch
-                {
-                    continue;
-                }
-            }
-
-            string lastName = string.Empty;
-            versions.Sort(LocalPlugin.Compare);
-            // Traverse backwards through the list.
-            // The first item will be the latest version.
-            for (int x = versions.Count - 1; x >= 0; x--)
-            {
-                if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase))
-                {
-                    versions[x].DllFiles.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories));
-                    lastName = versions[x].Name;
-                    continue;
-                }
-
-                if (!string.IsNullOrEmpty(lastName) && cleanup)
-                {
-                    // Attempt a cleanup of old folders.
-                    try
-                    {
-                        Logger.LogDebug("Deleting {Path}", versions[x].Path);
-                        Directory.Delete(versions[x].Path, true);
-                    }
-                    catch (Exception e)
-                    {
-                        Logger.LogWarning(e, "Unable to delete {Path}", versions[x].Path);
-                    }
-
-                    versions.RemoveAt(x);
-                }
-            }
-
-            return versions;
-        }
-
         /// <summary>
         /// Gets the composable part assemblies.
         /// </summary>
         /// <returns>IEnumerable{Assembly}.</returns>
         protected IEnumerable<Assembly> GetComposablePartAssemblies()
         {
-            if (Directory.Exists(ApplicationPaths.PluginsPath))
+            foreach (var p in _pluginManager.LoadAssemblies())
             {
-                _pluginsManifests = GetLocalPlugins(ApplicationPaths.PluginsPath).ToList();
-                foreach (var plugin in _pluginsManifests)
-                {
-                    foreach (var file in plugin.DllFiles)
-                    {
-                        Assembly plugAss;
-                        try
-                        {
-                            plugAss = Assembly.LoadFrom(file);
-                        }
-                        catch (FileLoadException ex)
-                        {
-                            Logger.LogError(ex, "Failed to load assembly {Path}", file);
-                            continue;
-                        }
-
-                        Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file);
-                        yield return plugAss;
-                    }
-                }
+                yield return p;
             }
 
             // Include composable parts in the Model assembly
@@ -1369,17 +1252,6 @@ namespace Emby.Server.Implementations
             }
         }
 
-        /// <summary>
-        /// Removes the plugin.
-        /// </summary>
-        /// <param name="plugin">The plugin.</param>
-        public void RemovePlugin(IPlugin plugin)
-        {
-            var list = _plugins.ToList();
-            list.Remove(plugin);
-            _plugins = list.ToArray();
-        }
-
         public IEnumerable<Assembly> GetApiPluginAssemblies()
         {
             var assemblies = _allConcreteTypes

+ 7 - 1
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -73,6 +73,12 @@
     <EmbeddedResource Include="Localization\countries.json" />
     <EmbeddedResource Include="Localization\Core\*.json" />
     <EmbeddedResource Include="Localization\Ratings\*.csv" />
+    <EmbeddedResource Include="Plugins\blank.png" />
+    <EmbeddedResource Include="Plugins\Superceded.png" />
+    <EmbeddedResource Include="Plugins\Disabled.png" />
+    <EmbeddedResource Include="Plugins\NotSupported.png" />
+    <EmbeddedResource Include="Plugins\Malfunction.png" />
+    <EmbeddedResource Include="Plugins\RestartRequired.png" />
+    <EmbeddedResource Include="Plugins\Active.png" />
   </ItemGroup>
-
 </Project>

BIN
Emby.Server.Implementations/Plugins/Active.png


BIN
Emby.Server.Implementations/Plugins/Malfunction.png


BIN
Emby.Server.Implementations/Plugins/NotSupported.png


+ 674 - 0
Emby.Server.Implementations/Plugins/PluginManager.cs

@@ -0,0 +1,674 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Plugins;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations
+{
+    /// <summary>
+    /// Defines the <see cref="PluginManager" />.
+    /// </summary>
+    public class PluginManager : IPluginManager
+    {
+        private const int OffsetFromTopRightCorner = 38;
+
+        private readonly string _pluginsPath;
+        private readonly Version _appVersion;
+        private readonly JsonSerializerOptions _jsonOptions;
+        private readonly ILogger<PluginManager> _logger;
+        private readonly IApplicationHost _appHost;
+        private readonly string _imagesPath;
+        private readonly ServerConfiguration _config;
+        private readonly IList<LocalPlugin> _plugins;
+        private readonly Version _nextVersion;
+        private readonly Version _minimumVersion;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PluginManager"/> class.
+        /// </summary>
+        /// <param name="loggerfactory">The <see cref="ILoggerFactory"/>.</param>
+        /// <param name="appHost">The <see cref="IApplicationHost"/>.</param>
+        /// <param name="config">The <see cref="ServerConfiguration"/>.</param>
+        /// <param name="pluginsPath">The plugin path.</param>
+        /// <param name="imagesPath">The image cache path.</param>
+        /// <param name="appVersion">The application version.</param>
+        public PluginManager(
+            ILoggerFactory loggerfactory,
+            IApplicationHost appHost,
+            ServerConfiguration config,
+            string pluginsPath,
+            string imagesPath,
+            Version appVersion)
+        {
+            _logger = loggerfactory.CreateLogger<PluginManager>();
+            _pluginsPath = pluginsPath;
+            _appVersion = appVersion ?? throw new ArgumentNullException(nameof(appVersion));
+            _jsonOptions = JsonDefaults.GetOptions();
+            _jsonOptions.PropertyNameCaseInsensitive = true;
+            _jsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+            _config = config;
+            _appHost = appHost;
+            _imagesPath = imagesPath;
+            _nextVersion = new Version(_appVersion.Major, _appVersion.Minor + 2, _appVersion.Build, _appVersion.Revision);
+            _minimumVersion = new Version(0, 0, 0, 1);
+            _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>();
+        }
+
+        /// <summary>
+        /// Gets the Plugins.
+        /// </summary>
+        public IList<LocalPlugin> Plugins => _plugins;
+
+        /// <summary>
+        /// Returns all the assemblies.
+        /// </summary>
+        /// <returns>An IEnumerable{Assembly}.</returns>
+        public IEnumerable<Assembly> LoadAssemblies()
+        {
+            foreach (var plugin in _plugins)
+            {
+                foreach (var file in plugin.DllFiles)
+                {
+                    try
+                    {
+                        plugin.Assembly = Assembly.LoadFrom(file);
+                    }
+                    catch (FileLoadException ex)
+                    {
+                        _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file);
+                        ChangePluginState(plugin, PluginStatus.Malfunction);
+                        continue;
+                    }
+
+                    _logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugin.Assembly.FullName, file);
+                    yield return plugin.Assembly;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Creates all the plugin instances.
+        /// </summary>
+        public void CreatePlugins()
+        {
+            var createdPlugins = _appHost.GetExports<IPlugin>(CreatePluginInstance)
+                .Where(i => i != null)
+                .ToArray();
+        }
+
+        /// <summary>
+        /// Registers the plugin's services with the DI.
+        /// Note: DI is not yet instantiated yet.
+        /// </summary>
+        /// <param name="serviceCollection">A <see cref="ServiceCollection"/> instance.</param>
+        public void RegisterServices(IServiceCollection serviceCollection)
+        {
+            foreach (var pluginServiceRegistrator in _appHost.GetExportTypes<IPluginServiceRegistrator>())
+            {
+                var plugin = GetPluginByType(pluginServiceRegistrator.Assembly.GetType());
+                if (plugin == null)
+                {
+                    throw new NullReferenceException();
+                }
+
+                CheckIfStillSuperceded(plugin);
+                if (!plugin.IsEnabledAndSupported)
+                {
+                    continue;
+                }
+
+                try
+                {
+                    var instance = (IPluginServiceRegistrator?)Activator.CreateInstance(pluginServiceRegistrator);
+                    instance?.RegisterServices(serviceCollection);
+                }
+#pragma warning disable CA1031 // Do not catch general exception types
+                catch (Exception ex)
+#pragma warning restore CA1031 // Do not catch general exception types
+                {
+                    _logger.LogError(ex, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator.Assembly.FullName);
+                    if (ChangePluginState(plugin, PluginStatus.Malfunction))
+                    {
+                        _logger.LogInformation("Disabling plugin {Path}", plugin.Path);
+                    }
+                }
+            }
+        }
+
+        /// <summary>
+        /// Imports a plugin manifest from <paramref name="folder"/>.
+        /// </summary>
+        /// <param name="folder">Folder of the plugin.</param>
+        public void ImportPluginFrom(string folder)
+        {
+            if (string.IsNullOrEmpty(folder))
+            {
+                throw new ArgumentNullException(nameof(folder));
+            }
+
+            // Load the plugin.
+            var plugin = LoadManifest(folder);
+            // Make sure we haven't already loaded this.
+            if (plugin == null || _plugins.Any(p => p.Manifest.Equals(plugin.Manifest)))
+            {
+                return;
+            }
+
+            _plugins.Add(plugin);
+            EnablePlugin(plugin);
+        }
+
+        /// <summary>
+        /// Removes the plugin reference '<paramref name="plugin"/>.
+        /// </summary>
+        /// <param name="plugin">The plugin.</param>
+        /// <returns>Outcome of the operation.</returns>
+        public bool RemovePlugin(LocalPlugin plugin)
+        {
+            if (plugin == null)
+            {
+                throw new ArgumentNullException(nameof(plugin));
+            }
+
+            plugin.Instance?.OnUninstalling();
+
+            if (DeletePlugin(plugin))
+            {
+                return true;
+            }
+
+            // Unable to delete, so disable.
+            return ChangePluginState(plugin, PluginStatus.Disabled);
+        }
+
+        /// <summary>
+        /// Attempts to find the plugin with and id of <paramref name="id"/>.
+        /// </summary>
+        /// <param name="id">Id of plugin.</param>
+        /// <param name="version">The version of the plugin to locate.</param>
+        /// <param name="plugin">A <see cref="LocalPlugin"/> if found, otherwise null.</param>
+        /// <returns>Boolean value signifying the success of the search.</returns>
+        public bool TryGetPlugin(Guid id, Version? version, out LocalPlugin? plugin)
+        {
+            if (version == null)
+            {
+                // If no version is given, return the largest version number. (This is for backwards compatibility).
+                plugin = _plugins.Where(p => p.Id.Equals(id)).OrderByDescending(p => p.Version).FirstOrDefault();
+            }
+            else
+            {
+                plugin = _plugins.FirstOrDefault(p => p.Id.Equals(id) && p.Version.Equals(version));
+            }
+
+            return plugin != null;
+        }
+
+        /// <summary>
+        /// Enables the plugin, disabling all other versions.
+        /// </summary>
+        /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param>
+        public void EnablePlugin(LocalPlugin plugin)
+        {
+            if (plugin == null)
+            {
+                throw new ArgumentNullException(nameof(plugin));
+            }
+
+            if (ChangePluginState(plugin, PluginStatus.Active))
+            {
+                UpdateSuccessors(plugin);
+            }
+        }
+
+        /// <summary>
+        /// Disable the plugin.
+        /// </summary>
+        /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param>
+        public void DisablePlugin(LocalPlugin plugin)
+        {
+            if (plugin == null)
+            {
+                throw new ArgumentNullException(nameof(plugin));
+            }
+
+            // Update the manifest on disk
+            if (ChangePluginState(plugin, PluginStatus.Disabled))
+            {
+                UpdateSuccessors(plugin);
+            }
+        }
+
+        /// <summary>
+        /// Changes the status of the other versions of the plugin to "Superceded".
+        /// </summary>
+        /// <param name="plugin">The <see cref="LocalPlugin"/> that's master.</param>
+        private void UpdateSuccessors(LocalPlugin plugin)
+        {
+            // This value is memory only - so that the web will show restart required.
+            plugin.Manifest.Status = PluginStatus.RestartRequired;
+
+            // Detect whether there is another version of this plugin that needs disabling.
+            var predecessor = _plugins.OrderByDescending(p => p.Version)
+                .FirstOrDefault(
+                    p => p.Id.Equals(plugin.Id)
+                    && p.Name.Equals(plugin.Name, StringComparison.OrdinalIgnoreCase)
+                    && p.IsEnabledAndSupported
+                    && p.Version != plugin.Version);
+
+            if (predecessor == null)
+            {
+                return;
+            }
+
+            if (!ChangePluginState(predecessor, PluginStatus.Superceded))
+            {
+                _logger.LogError("Unable to disable version {Version} of {Name}", predecessor.Version, predecessor.Name);
+            }
+        }
+
+        /// <summary>
+        /// Disable the plugin.
+        /// </summary>
+        /// <param name="assembly">The <see cref="Assembly"/> of the plug to disable.</param>
+        public void FailPlugin(Assembly assembly)
+        {
+            // Only save if disabled.
+            if (assembly == null)
+            {
+                throw new ArgumentNullException(nameof(assembly));
+            }
+
+            var plugin = _plugins.Where(p => assembly.Equals(p.Assembly)).FirstOrDefault();
+            if (plugin == null)
+            {
+                // A plugin's assembly didn't cause this issue, so ignore it.
+                return;
+            }
+
+            ChangePluginState(plugin, PluginStatus.Malfunction);
+        }
+
+        /// <summary>
+        /// Saves the manifest back to disk.
+        /// </summary>
+        /// <param name="manifest">The <see cref="PluginManifest"/> to save.</param>
+        /// <param name="path">The path where to save the manifest.</param>
+        /// <returns>True if successful.</returns>
+        public bool SaveManifest(PluginManifest manifest, string path)
+        {
+            if (manifest == null)
+            {
+                return false;
+            }
+
+            try
+            {
+                var data = JsonSerializer.Serialize(manifest, _jsonOptions);
+                File.WriteAllText(Path.Combine(path, "meta.json"), data, Encoding.UTF8);
+                return true;
+            }
+#pragma warning disable CA1031 // Do not catch general exception types
+            catch (Exception e)
+#pragma warning restore CA1031 // Do not catch general exception types
+            {
+                _logger.LogWarning(e, "Unable to save plugin manifest. {Path}", path);
+                return false;
+            }
+        }
+
+        /// <summary>
+        /// Changes a plugin's load status.
+        /// </summary>
+        /// <param name="plugin">The <see cref="LocalPlugin"/> instance.</param>
+        /// <param name="state">The <see cref="PluginStatus"/> of the plugin.</param>
+        /// <returns>Success of the task.</returns>
+        private bool ChangePluginState(LocalPlugin plugin, PluginStatus state)
+        {
+            if (plugin.Manifest.Status == state || string.IsNullOrEmpty(plugin.Path))
+            {
+                // No need to save as the state hasn't changed.
+                return true;
+            }
+
+            plugin.Manifest.Status = state;
+            SaveManifest(plugin.Manifest, plugin.Path);
+            try
+            {
+                var data = JsonSerializer.Serialize(plugin.Manifest, _jsonOptions);
+                File.WriteAllText(Path.Combine(plugin.Path, "meta.json"), data, Encoding.UTF8);
+                return true;
+            }
+#pragma warning disable CA1031 // Do not catch general exception types
+            catch (Exception e)
+#pragma warning restore CA1031 // Do not catch general exception types
+            {
+                _logger.LogWarning(e, "Unable to disable plugin {Path}", plugin.Path);
+                return false;
+            }
+        }
+
+        /// <summary>
+        /// Finds the plugin record using the type.
+        /// </summary>
+        /// <param name="type">The <see cref="Type"/> being sought.</param>
+        /// <returns>The matching record, or null if not found.</returns>
+        private LocalPlugin? GetPluginByType(Type type)
+        {
+            // Find which plugin it is by the path.
+            return _plugins.FirstOrDefault(p => string.Equals(p.Path, Path.GetDirectoryName(type.Assembly.Location), StringComparison.Ordinal));
+        }
+
+        /// <summary>
+        /// Creates the instance safe.
+        /// </summary>
+        /// <param name="type">The type.</param>
+        /// <returns>System.Object.</returns>
+        private object? CreatePluginInstance(Type type)
+        {
+            // Find the record for this plugin.
+            var plugin = GetPluginByType(type);
+
+            if (plugin != null)
+            {
+                CheckIfStillSuperceded(plugin);
+
+                if (plugin.IsEnabledAndSupported == true)
+                {
+                    _logger.LogInformation("Skipping disabled plugin {Version} of {Name} ", plugin.Version, plugin.Name);
+                    return null;
+                }
+            }
+
+            try
+            {
+                _logger.LogDebug("Creating instance of {Type}", type);
+                var instance = ActivatorUtilities.CreateInstance(_appHost.ServiceProvider, type);
+                if (plugin == null)
+                {
+                    // Create a dummy record for the providers.
+                    var pInstance = (IPlugin)instance;
+                    plugin = new LocalPlugin(
+                        pInstance.AssemblyFilePath,
+                        true,
+                        new PluginManifest
+                        {
+                            Guid = pInstance.Id,
+                            Status = PluginStatus.Active,
+                            Name = pInstance.Name,
+                            Version = pInstance.Version.ToString(),
+                            MaxAbi = _nextVersion.ToString()
+                        })
+                    {
+                        Instance = pInstance
+                    };
+
+                    _plugins.Add(plugin);
+
+                    plugin.Manifest.Status = PluginStatus.Active;
+                }
+                else
+                {
+                    plugin.Instance = (IPlugin)instance;
+                    var manifest = plugin.Manifest;
+                    var pluginStr = plugin.Instance.Version.ToString();
+                    if (string.Equals(manifest.Version, pluginStr, StringComparison.Ordinal))
+                    {
+                        // If a plugin without a manifest failed to load due to an external issue (eg config),
+                        // this updates the manifest to the actual plugin values.
+                        manifest.Version = pluginStr;
+                        manifest.Name = plugin.Instance.Name;
+                        manifest.Description = plugin.Instance.Description;
+                    }
+
+                    manifest.Status = PluginStatus.Active;
+                    SaveManifest(manifest, plugin.Path);
+                }
+
+                _logger.LogInformation("Loaded plugin: {PluginName} {PluginVersion}", plugin.Name, plugin.Version);
+
+                return instance;
+            }
+#pragma warning disable CA1031 // Do not catch general exception types
+            catch (Exception ex)
+#pragma warning restore CA1031 // Do not catch general exception types
+            {
+                _logger.LogError(ex, "Error creating {Type}", type.FullName);
+                if (plugin != null)
+                {
+                    if (ChangePluginState(plugin, PluginStatus.Malfunction))
+                    {
+                        _logger.LogInformation("Plugin {Path} has been disabled.", plugin.Path);
+                        return null;
+                    }
+                }
+
+                _logger.LogDebug("Unable to auto-disable.");
+                return null;
+            }
+        }
+
+        private void CheckIfStillSuperceded(LocalPlugin plugin)
+        {
+            if (plugin.Manifest.Status != PluginStatus.Superceded)
+            {
+                return;
+            }
+
+            var predecessor = _plugins.OrderByDescending(p => p.Version)
+                .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version);
+            if (predecessor != null)
+            {
+                return;
+            }
+
+            plugin.Manifest.Status = PluginStatus.Active;
+        }
+
+        /// <summary>
+        /// Attempts to delete a plugin.
+        /// </summary>
+        /// <param name="plugin">A <see cref="LocalPlugin"/> instance to delete.</param>
+        /// <returns>True if successful.</returns>
+        private bool DeletePlugin(LocalPlugin plugin)
+        {
+            // Attempt a cleanup of old folders.
+            try
+            {
+                _logger.LogDebug("Deleting {Path}", plugin.Path);
+                Directory.Delete(plugin.Path, true);
+            }
+#pragma warning disable CA1031 // Do not catch general exception types
+            catch (Exception e)
+#pragma warning restore CA1031 // Do not catch general exception types
+            {
+                _logger.LogWarning(e, "Unable to delete {Path}", plugin.Path);
+                return false;
+            }
+
+            return _plugins.Remove(plugin);
+        }
+
+        private LocalPlugin? LoadManifest(string dir)
+        {
+            try
+            {
+                Version? version;
+                PluginManifest? manifest = null;
+                var metafile = Path.Combine(dir, "meta.json");
+                if (File.Exists(metafile))
+                {
+                    try
+                    {
+                        var data = File.ReadAllText(metafile, Encoding.UTF8);
+                        manifest = JsonSerializer.Deserialize<PluginManifest>(data, _jsonOptions);
+                    }
+#pragma warning disable CA1031 // Do not catch general exception types
+                    catch (Exception ex)
+#pragma warning restore CA1031 // Do not catch general exception types
+                    {
+                        _logger.LogError(ex, "Error deserializing {Path}.", dir);
+                    }
+                }
+
+                if (manifest != null)
+                {
+                    if (!Version.TryParse(manifest.TargetAbi, out var targetAbi))
+                    {
+                        targetAbi = _minimumVersion;
+                    }
+
+                    if (!Version.TryParse(manifest.MaxAbi, out var maxAbi))
+                    {
+                        maxAbi = _appVersion;
+                    }
+
+                    if (!Version.TryParse(manifest.Version, out version))
+                    {
+                        manifest.Version = _minimumVersion.ToString();
+                    }
+
+                    return new LocalPlugin(dir, _appVersion >= targetAbi && _appVersion <= maxAbi, manifest);
+                }
+
+                // No metafile, so lets see if the folder is versioned.
+                // TODO: Phase this support out in future versions.
+                metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1];
+                int versionIndex = dir.LastIndexOf('_');
+                if (versionIndex != -1)
+                {
+                    // Get the version number from the filename if possible.
+                    metafile = Path.GetFileName(dir[..versionIndex]) ?? dir[..versionIndex];
+                    version = Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version? parsedVersion) ? parsedVersion : _appVersion;
+                }
+                else
+                {
+                    // Un-versioned folder - Add it under the path name and version it suitable for this instance.
+                    version = _appVersion;
+                }
+
+                // Auto-create a plugin manifest, so we can disable it, if it fails to load.
+                // NOTE: This Plugin is marked as valid for two upgrades, at which point, it can be assumed the
+                // code base will have changed sufficiently to make it invalid.
+                manifest = new PluginManifest
+                {
+                    Status = PluginStatus.RestartRequired,
+                    Name = metafile,
+                    AutoUpdate = false,
+                    Guid = metafile.GetMD5(),
+                    TargetAbi = _appVersion.ToString(),
+                    MaxAbi = _nextVersion.ToString(),
+                    Version = version.ToString()
+                };
+
+                return new LocalPlugin(dir, true, manifest);
+            }
+#pragma warning disable CA1031 // Do not catch general exception types
+            catch (Exception ex)
+#pragma warning restore CA1031 // Do not catch general exception types
+            {
+                _logger.LogError(ex, "Something went wrong!");
+                return null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the list of local plugins.
+        /// </summary>
+        /// <returns>Enumerable of local plugins.</returns>
+        private IEnumerable<LocalPlugin> DiscoverPlugins()
+        {
+            var versions = new List<LocalPlugin>();
+
+            if (!Directory.Exists(_pluginsPath))
+            {
+                // Plugin path doesn't exist, don't try to enumerate sub-folders.
+                return Enumerable.Empty<LocalPlugin>();
+            }
+
+            var directories = Directory.EnumerateDirectories(_pluginsPath, "*.*", SearchOption.TopDirectoryOnly);
+            LocalPlugin? entry;
+            foreach (var dir in directories)
+            {
+                entry = LoadManifest(dir);
+                if (entry != null)
+                {
+                    versions.Add(entry);
+                }
+            }
+
+            string lastName = string.Empty;
+            versions.Sort(LocalPlugin.Compare);
+            // Traverse backwards through the list.
+            // The first item will be the latest version.
+            for (int x = versions.Count - 1; x >= 0; x--)
+            {
+                entry = versions[x];
+                if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase))
+                {
+                    entry.DllFiles.AddRange(Directory.EnumerateFiles(entry.Path, "*.dll", SearchOption.AllDirectories));
+                    if (entry.IsEnabledAndSupported)
+                    {
+                        lastName = entry.Name;
+                        continue;
+                    }
+                }
+
+                if (string.IsNullOrEmpty(lastName))
+                {
+                    continue;
+                }
+
+                var manifest = entry.Manifest;
+                var cleaned = false;
+                var path = entry.Path;
+                if (_config.RemoveOldPlugins)
+                {
+                    // Attempt a cleanup of old folders.
+                    try
+                    {
+                        _logger.LogDebug("Deleting {Path}", path);
+                        Directory.Delete(path, true);
+                        cleaned = true;
+                    }
+#pragma warning disable CA1031 // Do not catch general exception types
+                    catch (Exception e)
+#pragma warning restore CA1031 // Do not catch general exception types
+                    {
+                        _logger.LogWarning(e, "Unable to delete {Path}", path);
+                    }
+
+                    versions.RemoveAt(x);
+                }
+
+                if (!cleaned)
+                {
+                    if (manifest == null)
+                    {
+                        _logger.LogWarning("Unable to disable plugin {Path}", entry.Path);
+                        continue;
+                    }
+
+                    // Update the manifest so its not loaded next time.
+                    manifest.Status = PluginStatus.Disabled;
+                    SaveManifest(manifest, entry.Path);
+                }
+            }
+
+            // Only want plugin folders which have files.
+            return versions.Where(p => p.DllFiles.Count != 0);
+        }
+    }
+}

+ 0 - 60
Emby.Server.Implementations/Plugins/PluginManifest.cs

@@ -1,60 +0,0 @@
-using System;
-
-namespace Emby.Server.Implementations.Plugins
-{
-    /// <summary>
-    /// Defines a Plugin manifest file.
-    /// </summary>
-    public class PluginManifest
-    {
-        /// <summary>
-        /// Gets or sets the category of the plugin.
-        /// </summary>
-        public string Category { get; set; }
-
-        /// <summary>
-        /// Gets or sets the changelog information.
-        /// </summary>
-        public string Changelog { get; set; }
-
-        /// <summary>
-        /// Gets or sets the description of the plugin.
-        /// </summary>
-        public string Description { get; set; }
-
-        /// <summary>
-        /// Gets or sets the Global Unique Identifier for the plugin.
-        /// </summary>
-        public Guid Guid { get; set; }
-
-        /// <summary>
-        /// Gets or sets the Name of the plugin.
-        /// </summary>
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets an overview of the plugin.
-        /// </summary>
-        public string Overview { get; set; }
-
-        /// <summary>
-        /// Gets or sets the owner of the plugin.
-        /// </summary>
-        public string Owner { get; set; }
-
-        /// <summary>
-        /// Gets or sets the compatibility version for the plugin.
-        /// </summary>
-        public string TargetAbi { get; set; }
-
-        /// <summary>
-        /// Gets or sets the timestamp of the plugin.
-        /// </summary>
-        public DateTime Timestamp { get; set; }
-
-        /// <summary>
-        /// Gets or sets the Version number of the plugin.
-        /// </summary>
-        public string Version { get; set; }
-    }
-}

BIN
Emby.Server.Implementations/Plugins/RestartRequired.png


BIN
Emby.Server.Implementations/Plugins/Superceded.png


BIN
Emby.Server.Implementations/Plugins/blank.png


BIN
Emby.Server.Implementations/Plugins/disabled.png


+ 1 - 1
Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs

@@ -8,10 +8,10 @@ using System.Net.Http;
 using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Common.Updates;
+using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Tasks;
 using Microsoft.Extensions.Logging;
-using MediaBrowser.Model.Globalization;
 
 namespace Emby.Server.Implementations.ScheduledTasks
 {

+ 248 - 208
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#nullable enable
 
 using System;
 using System.Collections.Concurrent;
@@ -12,7 +12,6 @@ using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Events;
-using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Net;
@@ -41,17 +40,15 @@ namespace Emby.Server.Implementations.Updates
         private readonly IEventManager _eventManager;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IServerConfigurationManager _config;
-        private readonly IFileSystem _fileSystem;
         private readonly JsonSerializerOptions _jsonSerializerOptions;
+        private readonly IPluginManager _pluginManager;
 
         /// <summary>
         /// Gets the application host.
         /// </summary>
         /// <value>The application host.</value>
         private readonly IServerApplicationHost _applicationHost;
-
         private readonly IZipClient _zipClient;
-
         private readonly object _currentInstallationsLock = new object();
 
         /// <summary>
@@ -64,6 +61,17 @@ namespace Emby.Server.Implementations.Updates
         /// </summary>
         private readonly ConcurrentBag<InstallationInfo> _completedInstallationsInternal;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="InstallationManager"/> class.
+        /// </summary>
+        /// <param name="logger">The <see cref="ILogger{InstallationManager}"/>.</param>
+        /// <param name="appHost">The <see cref="IServerApplicationHost"/>.</param>
+        /// <param name="appPaths">The <see cref="IApplicationPaths"/>.</param>
+        /// <param name="eventManager">The <see cref="IEventManager"/>.</param>
+        /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
+        /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
+        /// <param name="zipClient">The <see cref="IZipClient"/>.</param>
+        /// <param name="pluginManager">The <see cref="IPluginManager"/>.</param>
         public InstallationManager(
             ILogger<InstallationManager> logger,
             IServerApplicationHost appHost,
@@ -71,8 +79,8 @@ namespace Emby.Server.Implementations.Updates
             IEventManager eventManager,
             IHttpClientFactory httpClientFactory,
             IServerConfigurationManager config,
-            IFileSystem fileSystem,
-            IZipClient zipClient)
+            IZipClient zipClient,
+            IPluginManager pluginManager)
         {
             _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
             _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
@@ -83,16 +91,17 @@ namespace Emby.Server.Implementations.Updates
             _eventManager = eventManager;
             _httpClientFactory = httpClientFactory;
             _config = config;
-            _fileSystem = fileSystem;
             _zipClient = zipClient;
             _jsonSerializerOptions = JsonDefaults.GetOptions();
+            _jsonSerializerOptions.PropertyNameCaseInsensitive = true;
+            _pluginManager = pluginManager;
         }
 
         /// <inheritdoc />
         public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
 
         /// <inheritdoc />
-        public async Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default)
+        public async Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, bool filterIncompatible, CancellationToken cancellationToken = default)
         {
             try
             {
@@ -103,13 +112,39 @@ namespace Emby.Server.Implementations.Updates
                     return Array.Empty<PackageInfo>();
                 }
 
+                var minimumVersion = new Version(0, 0, 0, 1);
                 // Store the repository and repository url with each version, as they may be spread apart.
                 foreach (var entry in packages)
                 {
-                    foreach (var ver in entry.versions)
+                    for (int a = entry.Versions.Count - 1; a >= 0; a--)
                     {
-                        ver.repositoryName = manifestName;
-                        ver.repositoryUrl = manifest;
+                        var ver = entry.Versions[a];
+                        ver.RepositoryName = manifestName;
+                        ver.RepositoryUrl = manifest;
+
+                        if (!filterIncompatible)
+                        {
+                            continue;
+                        }
+
+                        if (!Version.TryParse(ver.TargetAbi, out var targetAbi))
+                        {
+                            targetAbi = minimumVersion;
+                        }
+
+                        if (!Version.TryParse(ver.MaxAbi, out var maxAbi))
+                        {
+                            maxAbi = _applicationHost.ApplicationVersion;
+                        }
+
+                        // Only show plugins that fall between targetAbi and maxAbi
+                        if (_applicationHost.ApplicationVersion >= targetAbi && _applicationHost.ApplicationVersion <= maxAbi)
+                        {
+                            continue;
+                        }
+
+                        // Not compatible with this version so remove it.
+                        entry.Versions.Remove(ver);
                     }
                 }
 
@@ -132,69 +167,61 @@ namespace Emby.Server.Implementations.Updates
             }
         }
 
-        private static void MergeSort(IList<VersionInfo> source, IList<VersionInfo> dest)
-        {
-            int sLength = source.Count - 1;
-            int dLength = dest.Count;
-            int s = 0, d = 0;
-            var sourceVersion = source[0].VersionNumber;
-            var destVersion = dest[0].VersionNumber;
-
-            while (d < dLength)
-            {
-                if (sourceVersion.CompareTo(destVersion) >= 0)
-                {
-                    if (s < sLength)
-                    {
-                        sourceVersion = source[++s].VersionNumber;
-                    }
-                    else
-                    {
-                        // Append all of destination to the end of source.
-                        while (d < dLength)
-                        {
-                            source.Add(dest[d++]);
-                        }
-
-                        break;
-                    }
-                }
-                else
-                {
-                    source.Insert(s++, dest[d++]);
-                    if (d >= dLength)
-                    {
-                        break;
-                    }
-
-                    sLength++;
-                    destVersion = dest[d].VersionNumber;
-                }
-            }
-        }
-
         /// <inheritdoc />
         public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
         {
             var result = new List<PackageInfo>();
             foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
             {
-                if (repository.Enabled)
+                if (repository.Enabled && repository.Url != null)
                 {
-                    // Where repositories have the same content, the details of the first is taken.
-                    foreach (var package in await GetPackages(repository.Name, repository.Url, cancellationToken).ConfigureAwait(true))
+                    // Where repositories have the same content, the details from the first is taken.
+                    foreach (var package in await GetPackages(repository.Name ?? "Unnamed Repo", repository.Url, true, cancellationToken).ConfigureAwait(true))
                     {
-                        if (!Guid.TryParse(package.guid, out var packageGuid))
+                        if (!Guid.TryParse(package.Guid, out var packageGuid))
                         {
                             // Package doesn't have a valid GUID, skip.
                             continue;
                         }
 
-                        var existing = FilterPackages(result, package.name, packageGuid).FirstOrDefault();
+                        var existing = FilterPackages(result, package.Name, packageGuid).FirstOrDefault();
+
+                        // Remove invalid versions from the valid package.
+                        for (var i = package.Versions.Count - 1; i >= 0; i--)
+                        {
+                            var version = package.Versions[i];
+
+                            // Update the manifests, if anything changes.
+                            if (_pluginManager.TryGetPlugin(packageGuid, version.VersionNumber, out LocalPlugin? plugin))
+                            {
+                                bool noChange = string.Equals(plugin!.Manifest.MaxAbi, version.MaxAbi, StringComparison.Ordinal)
+                                    || string.Equals(plugin.Manifest.TargetAbi, version.TargetAbi, StringComparison.Ordinal);
+                                if (!noChange)
+                                {
+                                    plugin.Manifest.MaxAbi = version.MaxAbi ?? string.Empty;
+                                    plugin.Manifest.TargetAbi = version.TargetAbi ?? string.Empty;
+                                    _pluginManager.SaveManifest(plugin.Manifest, plugin.Path);
+                                }
+                            }
+
+                            // Remove versions with a target abi that is greater then the current application version.
+                            if ((Version.TryParse(version.TargetAbi, out var targetAbi) && _applicationHost.ApplicationVersion < targetAbi)
+                                || (Version.TryParse(version.MaxAbi, out var maxAbi) && _applicationHost.ApplicationVersion > maxAbi))
+                            {
+                                package.Versions.RemoveAt(i);
+                            }
+                        }
+
+                        // Don't add a package that doesn't have any compatible versions.
+                        if (package.Versions.Count == 0)
+                        {
+                            continue;
+                        }
+
                         if (existing != null)
                         {
                             // Assumption is both lists are ordered, so slot these into the correct place.
-                            MergeSort(existing.versions, package.versions);
+                            MergeSortedList(existing.Versions, package.Versions);
                         }
                         else
                         {
@@ -210,23 +237,23 @@ namespace Emby.Server.Implementations.Updates
         /// <inheritdoc />
         public IEnumerable<PackageInfo> FilterPackages(
             IEnumerable<PackageInfo> availablePackages,
-            string name = null,
-            Guid guid = default,
-            Version specificVersion = null)
+            string? name = null,
+            Guid? guid = default,
+            Version? specificVersion = null)
         {
             if (name != null)
             {
-                availablePackages = availablePackages.Where(x => x.name.Equals(name, StringComparison.OrdinalIgnoreCase));
+                availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
             }
 
             if (guid != Guid.Empty)
             {
-                availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
+                availablePackages = availablePackages.Where(x => Guid.Parse(x.Guid) == guid);
             }
 
             if (specificVersion != null)
             {
-                availablePackages = availablePackages.Where(x => x.versions.Where(y => y.VersionNumber.Equals(specificVersion)).Any());
+                availablePackages = availablePackages.Where(x => x.Versions.Any(y => y.VersionNumber.Equals(specificVersion)));
             }
 
             return availablePackages;
@@ -235,10 +262,10 @@ namespace Emby.Server.Implementations.Updates
         /// <inheritdoc />
         public IEnumerable<InstallationInfo> GetCompatibleVersions(
             IEnumerable<PackageInfo> availablePackages,
-            string name = null,
-            Guid guid = default,
-            Version minVersion = null,
-            Version specificVersion = null)
+            string? name = null,
+            Guid? guid = default,
+            Version? minVersion = null,
+            Version? specificVersion = null)
         {
             var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
 
@@ -249,8 +276,9 @@ namespace Emby.Server.Implementations.Updates
             }
 
             var appVer = _applicationHost.ApplicationVersion;
-            var availableVersions = package.versions
-                .Where(x => Version.Parse(x.targetAbi) <= appVer);
+            var availableVersions = package.Versions
+                .Where(x => (string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer)
+                    && (string.IsNullOrEmpty(x.MaxAbi) || Version.Parse(x.MaxAbi) >= appVer));
 
             if (specificVersion != null)
             {
@@ -265,12 +293,12 @@ namespace Emby.Server.Implementations.Updates
             {
                 yield return new InstallationInfo
                 {
-                    Changelog = v.changelog,
-                    Guid = new Guid(package.guid),
-                    Name = package.name,
+                    Changelog = v.Changelog,
+                    Guid = new Guid(package.Guid),
+                    Name = package.Name,
                     Version = v.VersionNumber,
-                    SourceUrl = v.sourceUrl,
-                    Checksum = v.checksum
+                    SourceUrl = v.SourceUrl,
+                    Checksum = v.Checksum
                 };
             }
         }
@@ -282,20 +310,6 @@ namespace Emby.Server.Implementations.Updates
             return GetAvailablePluginUpdates(catalog);
         }
 
-        private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
-        {
-            var plugins = _applicationHost.GetLocalPlugins(_appPaths.PluginsPath);
-            foreach (var plugin in plugins)
-            {
-                var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version);
-                var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
-                if (version != null && CompletedInstallations.All(x => x.Guid != version.Guid))
-                {
-                    yield return version;
-                }
-            }
-        }
-
         /// <inheritdoc />
         public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken)
         {
@@ -373,24 +387,140 @@ namespace Emby.Server.Implementations.Updates
         }
 
         /// <summary>
-        /// Installs the package internal.
+        /// Uninstalls a plugin.
         /// </summary>
-        /// <param name="package">The package.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <returns><see cref="Task" />.</returns>
-        private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
+        /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param>
+        public void UninstallPlugin(LocalPlugin plugin)
         {
-            // Set last update time if we were installed before
-            IPlugin plugin = _applicationHost.Plugins.FirstOrDefault(p => p.Id == package.Guid)
-                           ?? _applicationHost.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase));
+            if (plugin == null)
+            {
+                return;
+            }
 
-            // Do the install
-            await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false);
+            if (plugin.Instance?.CanUninstall == false)
+            {
+                _logger.LogWarning("Attempt to delete non removable plugin {0}, ignoring request", plugin.Name);
+                return;
+            }
 
-            // Do plugin-specific processing
-            _logger.LogInformation(plugin == null ? "New plugin installed: {0} {1}" : "Plugin updated: {0} {1}", package.Name, package.Version);
+            plugin.Instance?.OnUninstalling();
 
-            return plugin != null;
+            // Remove it the quick way for now
+            _pluginManager.RemovePlugin(plugin);
+
+            _eventManager.Publish(new PluginUninstalledEventArgs(plugin.GetPluginInfo()));
+
+            _applicationHost.NotifyPendingRestart();
+        }
+
+        /// <inheritdoc/>
+        public bool CancelInstallation(Guid id)
+        {
+            lock (_currentInstallationsLock)
+            {
+                var install = _currentInstallations.Find(x => x.info.Guid == id);
+                if (install == default((InstallationInfo, CancellationTokenSource)))
+                {
+                    return false;
+                }
+
+                install.token.Cancel();
+                _currentInstallations.Remove(install);
+                return true;
+            }
+        }
+
+        /// <inheritdoc />
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <summary>
+        /// Releases unmanaged and optionally managed resources.
+        /// </summary>
+        /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release only unmanaged resources.</param>
+        protected virtual void Dispose(bool dispose)
+        {
+            if (dispose)
+            {
+                lock (_currentInstallationsLock)
+                {
+                    foreach (var (info, token) in _currentInstallations)
+                    {
+                        token.Dispose();
+                    }
+
+                    _currentInstallations.Clear();
+                }
+            }
+        }
+
+        /// <summary>
+        /// Merges two sorted lists.
+        /// </summary>
+        /// <param name="source">The source <see cref="IList{VersionInfo}"/> instance to merge.</param>
+        /// <param name="dest">The destination <see cref="IList{VersionInfo}"/> instance to merge with.</param>
+        private static void MergeSortedList(IList<VersionInfo> source, IList<VersionInfo> dest)
+        {
+            int sLength = source.Count - 1;
+            int dLength = dest.Count;
+            int s = 0, d = 0;
+            var sourceVersion = source[0].VersionNumber;
+            var destVersion = dest[0].VersionNumber;
+
+            while (d < dLength)
+            {
+                if (sourceVersion.CompareTo(destVersion) >= 0)
+                {
+                    if (s < sLength)
+                    {
+                        sourceVersion = source[++s].VersionNumber;
+                    }
+                    else
+                    {
+                        // Append all of destination to the end of source.
+                        while (d < dLength)
+                        {
+                            source.Add(dest[d++]);
+                        }
+
+                        break;
+                    }
+                }
+                else
+                {
+                    source.Insert(s++, dest[d++]);
+                    if (d >= dLength)
+                    {
+                        break;
+                    }
+
+                    sLength++;
+                    destVersion = dest[d].VersionNumber;
+                }
+            }
+        }
+
+        private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
+        {
+            var plugins = _pluginManager.Plugins;
+            foreach (var plugin in plugins)
+            {
+                if (plugin.Manifest?.AutoUpdate == false)
+                {
+                    continue;
+                }
+
+                var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version);
+                var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
+
+                if (version != null && CompletedInstallations.All(x => x.Guid != version.Guid))
+                {
+                    yield return version;
+                }
+            }
         }
 
         private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken)
@@ -434,7 +564,9 @@ namespace Emby.Server.Implementations.Updates
                 {
                     Directory.Delete(targetDir, true);
                 }
+#pragma warning disable CA1031 // Do not catch general exception types
                 catch
+#pragma warning restore CA1031 // Do not catch general exception types
                 {
                     // Ignore any exceptions.
                 }
@@ -442,119 +574,27 @@ namespace Emby.Server.Implementations.Updates
 
             stream.Position = 0;
             _zipClient.ExtractAllFromZip(stream, targetDir, true);
-
-#pragma warning restore CA5351
+            _pluginManager.ImportPluginFrom(targetDir);
         }
 
-        /// <summary>
-        /// Uninstalls a plugin.
-        /// </summary>
-        /// <param name="plugin">The plugin.</param>
-        public void UninstallPlugin(IPlugin plugin)
-        {
-            if (!plugin.CanUninstall)
-            {
-                _logger.LogWarning("Attempt to delete non removable plugin {0}, ignoring request", plugin.Name);
-                return;
-            }
-
-            plugin.OnUninstalling();
-
-            // Remove it the quick way for now
-            _applicationHost.RemovePlugin(plugin);
-
-            var path = plugin.AssemblyFilePath;
-            bool isDirectory = false;
-            // Check if we have a plugin directory we should remove too
-            if (Path.GetDirectoryName(plugin.AssemblyFilePath) != _appPaths.PluginsPath)
-            {
-                path = Path.GetDirectoryName(plugin.AssemblyFilePath);
-                isDirectory = true;
-            }
-
-            // Make this case-insensitive to account for possible incorrect assembly naming
-            var file = _fileSystem.GetFilePaths(Path.GetDirectoryName(path))
-                .FirstOrDefault(i => string.Equals(i, path, StringComparison.OrdinalIgnoreCase));
-
-            if (!string.IsNullOrWhiteSpace(file))
-            {
-                path = file;
-            }
-
-            try
-            {
-                if (isDirectory)
-                {
-                    _logger.LogInformation("Deleting plugin directory {0}", path);
-                    Directory.Delete(path, true);
-                }
-                else
-                {
-                    _logger.LogInformation("Deleting plugin file {0}", path);
-                    _fileSystem.DeleteFile(path);
-                }
-            }
-            catch
-            {
-                // Ignore file errors.
-            }
-
-            var list = _config.Configuration.UninstalledPlugins.ToList();
-            var filename = Path.GetFileName(path);
-            if (!list.Contains(filename, StringComparer.OrdinalIgnoreCase))
-            {
-                list.Add(filename);
-                _config.Configuration.UninstalledPlugins = list.ToArray();
-                _config.SaveConfiguration();
-            }
-
-            _eventManager.Publish(new PluginUninstalledEventArgs(plugin));
-
-            _applicationHost.NotifyPendingRestart();
-        }
-
-        /// <inheritdoc/>
-        public bool CancelInstallation(Guid id)
+        private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
         {
-            lock (_currentInstallationsLock)
+            // Set last update time if we were installed before
+            LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Guid) && p.Version.Equals(package.Version))
+                  ?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase) && p.Version.Equals(package.Version));
+            if (plugin != null)
             {
-                var install = _currentInstallations.Find(x => x.info.Guid == id);
-                if (install == default((InstallationInfo, CancellationTokenSource)))
-                {
-                    return false;
-                }
-
-                install.token.Cancel();
-                _currentInstallations.Remove(install);
-                return true;
+                plugin.Manifest.Timestamp = DateTime.UtcNow;
+                _pluginManager.SaveManifest(plugin.Manifest, plugin.Path);
             }
-        }
 
-        /// <inheritdoc />
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
+            // Do the install
+            await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false);
 
-        /// <summary>
-        /// Releases unmanaged and optionally managed resources.
-        /// </summary>
-        /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release only unmanaged resources.</param>
-        protected virtual void Dispose(bool dispose)
-        {
-            if (dispose)
-            {
-                lock (_currentInstallationsLock)
-                {
-                    foreach (var tuple in _currentInstallations)
-                    {
-                        tuple.token.Dispose();
-                    }
+            // Do plugin-specific processing
+            _logger.LogInformation(plugin == null ? "New plugin installed: {0} {1}" : "Plugin updated: {0} {1}", package.Name, package.Version);
 
-                    _currentInstallations.Clear();
-                }
-            }
+            return plugin != null;
         }
     }
 }

+ 12 - 8
Jellyfin.Api/Controllers/DashboardController.cs

@@ -29,18 +29,22 @@ namespace Jellyfin.Api.Controllers
     {
         private readonly ILogger<DashboardController> _logger;
         private readonly IServerApplicationHost _appHost;
+        private readonly IPluginManager _pluginManager;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="DashboardController"/> class.
         /// </summary>
         /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
         /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param>
         public DashboardController(
             ILogger<DashboardController> logger,
-            IServerApplicationHost appHost)
+            IServerApplicationHost appHost,
+            IPluginManager pluginManager)
         {
             _logger = logger;
             _appHost = appHost;
+            _pluginManager = pluginManager;
         }
 
         /// <summary>
@@ -83,7 +87,7 @@ namespace Jellyfin.Api.Controllers
                 .Where(i => i != null)
                 .ToList();
 
-            configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages));
+            configPages.AddRange(_pluginManager.Plugins.SelectMany(GetConfigPages));
 
             if (pageType.HasValue)
             {
@@ -155,24 +159,24 @@ namespace Jellyfin.Api.Controllers
             return NotFound();
         }
 
-        private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin)
+        private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin)
         {
-            return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1));
+            return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1));
         }
 
-        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin)
+        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin? plugin)
         {
-            if (!(plugin is IHasWebPages hasWebPages))
+            if (plugin?.Instance is not IHasWebPages hasWebPages)
             {
                 return new List<Tuple<PluginPageInfo, IPlugin>>();
             }
 
-            return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin));
+            return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance));
         }
 
         private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
         {
-            return _appHost.Plugins.SelectMany(GetPluginPages);
+            return _pluginManager.Plugins.SelectMany(GetPluginPages);
         }
     }
 }

+ 7 - 1
Jellyfin.Api/Controllers/PackageController.cs

@@ -2,8 +2,11 @@ using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
+using System.Net.Mime;
 using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Updates;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Model.Updates;
@@ -43,6 +46,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
         [HttpGet("Packages/{name}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(JsonDefaults.CamelCaseMediaType)]
         public async Task<ActionResult<PackageInfo>> GetPackageInfo(
             [FromRoute, Required] string name,
             [FromQuery] Guid? assemblyGuid)
@@ -69,6 +73,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
         [HttpGet("Packages")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(JsonDefaults.CamelCaseMediaType)]
         public async Task<IEnumerable<PackageInfo>> GetPackages()
         {
             IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
@@ -99,7 +104,7 @@ namespace Jellyfin.Api.Controllers
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             if (!string.IsNullOrEmpty(repositoryUrl))
             {
-                packages = packages.Where(p => p.versions.Where(q => q.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)).Any())
+                packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)))
                     .ToList();
             }
 
@@ -143,6 +148,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns>
         [HttpGet("Repositories")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(JsonDefaults.CamelCaseMediaType)]
         public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories()
         {
             return _serverConfigurationManager.Configuration.PluginRepositories;

+ 220 - 68
Jellyfin.Api/Controllers/PluginsController.cs

@@ -1,15 +1,22 @@
 using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.IO;
 using System.Linq;
+using System.Net.Mime;
 using System.Text.Json;
 using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Models.PluginDtos;
-using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Plugins;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -23,22 +30,81 @@ namespace Jellyfin.Api.Controllers
     [Authorize(Policy = Policies.DefaultAuthorization)]
     public class PluginsController : BaseJellyfinApiController
     {
-        private readonly IApplicationHost _appHost;
         private readonly IInstallationManager _installationManager;
-
-        private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.GetOptions();
+        private readonly IPluginManager _pluginManager;
+        private readonly IConfigurationManager _config;
+        private readonly JsonSerializerOptions _serializerOptions;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="PluginsController"/> class.
         /// </summary>
-        /// <param name="appHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
         /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+        /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param>
+        /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
         public PluginsController(
-            IApplicationHost appHost,
-            IInstallationManager installationManager)
+            IInstallationManager installationManager,
+            IPluginManager pluginManager,
+            IConfigurationManager config)
         {
-            _appHost = appHost;
             _installationManager = installationManager;
+            _pluginManager = pluginManager;
+            _serializerOptions = JsonDefaults.GetOptions();
+            _config = config;
+        }
+
+        /// <summary>
+        /// Get plugin security info.
+        /// </summary>
+        /// <response code="200">Plugin security info returned.</response>
+        /// <returns>Plugin security info.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpGet("SecurityInfo")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public static ActionResult<PluginSecurityInfo> GetPluginSecurityInfo()
+        {
+            return new PluginSecurityInfo
+            {
+                IsMbSupporter = true,
+                SupporterKey = "IAmTotallyLegit"
+            };
+        }
+
+        /// <summary>
+        /// Gets registration status for a feature.
+        /// </summary>
+        /// <param name="name">Feature name.</param>
+        /// <response code="200">Registration status returned.</response>
+        /// <returns>Mb registration record.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpPost("RegistrationRecords/{name}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public static ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute, Required] string name)
+        {
+            return new MBRegistrationRecord
+            {
+                IsRegistered = true,
+                RegChecked = true,
+                TrialVersion = false,
+                IsValid = true,
+                RegError = false
+            };
+        }
+
+        /// <summary>
+        /// Gets registration status for a feature.
+        /// </summary>
+        /// <param name="name">Feature name.</param>
+        /// <response code="501">Not implemented.</response>
+        /// <returns>Not Implemented.</returns>
+        /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception>
+        [Obsolete("Paid plugins are not supported")]
+        [HttpGet("Registrations/{name}")]
+        [ProducesResponseType(StatusCodes.Status501NotImplemented)]
+        public static ActionResult GetRegistration([FromRoute, Required] string name)
+        {
+            // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins,
+            // delete all these registration endpoints. They are only kept for compatibility.
+            throw new NotImplementedException();
         }
 
         /// <summary>
@@ -48,15 +114,65 @@ namespace Jellyfin.Api.Controllers
         /// <returns>List of currently installed plugins.</returns>
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesFile(MediaTypeNames.Application.Json)]
         public ActionResult<IEnumerable<PluginInfo>> GetPlugins()
         {
-            return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo()));
+            return Ok(_pluginManager.Plugins
+                .OrderBy(p => p.Name)
+                .Select(p => p.GetPluginInfo()));
+        }
+
+        /// <summary>
+        /// Enables a disabled plugin.
+        /// </summary>
+        /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
+        /// <response code="204">Plugin enabled.</response>
+        /// <response code="404">Plugin not found.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+        [HttpPost("{pluginId}/Enable")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
+        {
+            if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
+            {
+                return NotFound();
+            }
+
+            _pluginManager.EnablePlugin(plugin!);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Disable a plugin.
+        /// </summary>
+        /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
+        /// <response code="204">Plugin disabled.</response>
+        /// <response code="404">Plugin not found.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+        [HttpPost("{pluginId}/Disable")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
+        {
+            if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
+            {
+                return NotFound();
+            }
+
+            _pluginManager.DisablePlugin(plugin!);
+            return NoContent();
         }
 
         /// <summary>
         /// Uninstalls a plugin.
         /// </summary>
         /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
         /// <response code="204">Plugin uninstalled.</response>
         /// <response code="404">Plugin not found.</response>
         /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
@@ -64,15 +180,14 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
+        public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId, Version version)
         {
-            var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
-            if (plugin == null)
+            if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
             {
                 return NotFound();
             }
 
-            _installationManager.UninstallPlugin(plugin);
+            _installationManager.UninstallPlugin(plugin!);
             return NoContent();
         }
 
@@ -80,20 +195,23 @@ namespace Jellyfin.Api.Controllers
         /// Gets plugin configuration.
         /// </summary>
         /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
         /// <response code="200">Plugin configuration returned.</response>
         /// <response code="404">Plugin not found or plugin configuration not found.</response>
         /// <returns>Plugin configuration.</returns>
         [HttpGet("{pluginId}/Configuration")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId)
+        [ProducesFile(MediaTypeNames.Application.Json)]
+        public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
         {
-            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            if (_pluginManager.TryGetPlugin(pluginId, version, out var plugin)
+                && plugin!.Instance is IHasPluginConfiguration configPlugin)
             {
-                return NotFound();
+                return configPlugin.Configuration;
             }
 
-            return plugin.Configuration;
+            return NotFound();
         }
 
         /// <summary>
@@ -103,6 +221,7 @@ namespace Jellyfin.Api.Controllers
         /// Accepts plugin configuration as JSON body.
         /// </remarks>
         /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
         /// <response code="204">Plugin configuration updated.</response>
         /// <response code="404">Plugin not found or plugin does not have configuration.</response>
         /// <returns>
@@ -113,92 +232,125 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("{pluginId}/Configuration")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId)
+        public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
         {
-            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin)
+                         || plugin?.Instance is not IHasPluginConfiguration configPlugin)
             {
                 return NotFound();
             }
 
-            var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType, _serializerOptions)
+            var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions)
                 .ConfigureAwait(false);
 
             if (configuration != null)
             {
-                plugin.UpdateConfiguration(configuration);
+                configPlugin.UpdateConfiguration(configuration);
             }
 
             return NoContent();
         }
 
         /// <summary>
-        /// Get plugin security info.
+        /// Gets a plugin's image.
         /// </summary>
-        /// <response code="200">Plugin security info returned.</response>
-        /// <returns>Plugin security info.</returns>
-        [Obsolete("This endpoint should not be used.")]
-        [HttpGet("SecurityInfo")]
+        /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
+        /// <response code="200">Plugin image returned.</response>
+        /// <returns>Plugin's image.</returns>
+        [HttpGet("{pluginId}/Image")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo()
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        [AllowAnonymous]
+        public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
         {
-            return new PluginSecurityInfo
+            if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
             {
-                IsMbSupporter = true,
-                SupporterKey = "IAmTotallyLegit"
-            };
+                return NotFound();
+            }
+
+            var imgPath = Path.Combine(plugin!.Path, plugin!.Manifest.ImageUrl ?? string.Empty);
+            if (((ServerConfiguration)_config.CommonConfiguration).DisablePluginImages
+                || plugin!.Manifest.ImageUrl == null
+                || !System.IO.File.Exists(imgPath))
+            {
+                // Use a blank image.
+                var type = GetType();
+                var stream = type.Assembly.GetManifestResourceStream(type.Namespace + ".Plugins.blank.png");
+                return File(stream, "image/png");
+            }
+
+            imgPath = Path.Combine(plugin.Path, plugin.Manifest.ImageUrl);
+            return PhysicalFile(imgPath, MimeTypes.GetMimeType(imgPath));
         }
 
         /// <summary>
-        /// Updates plugin security info.
+        /// Gets a plugin's status image.
         /// </summary>
-        /// <param name="pluginSecurityInfo">Plugin security info.</param>
-        /// <response code="204">Plugin security info updated.</response>
-        /// <returns>An <see cref="NoContentResult"/>.</returns>
-        [Obsolete("This endpoint should not be used.")]
-        [HttpPost("SecurityInfo")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdatePluginSecurityInfo([FromBody, Required] PluginSecurityInfo pluginSecurityInfo)
+        /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
+        /// <response code="200">Plugin image returned.</response>
+        /// <returns>Plugin's image.</returns>
+        [HttpGet("{pluginId}/StatusImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        [AllowAnonymous]
+        public ActionResult GetPluginStatusImage([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
         {
-            return NoContent();
+            if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
+            {
+                return NotFound();
+            }
+
+            // Icons from  http://www.fatcow.com/free-icons
+            var status = plugin!.Manifest.Status;
+
+            var type = _pluginManager.GetType();
+            var stream = type.Assembly.GetManifestResourceStream($"{type.Namespace}.Plugins.{status}.png");
+            return File(stream, "image/png");
         }
 
         /// <summary>
-        /// Gets registration status for a feature.
+        /// Gets a plugin's manifest.
         /// </summary>
-        /// <param name="name">Feature name.</param>
-        /// <response code="200">Registration status returned.</response>
-        /// <returns>Mb registration record.</returns>
-        [Obsolete("This endpoint should not be used.")]
-        [HttpPost("RegistrationRecords/{name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute, Required] string name)
+        /// <param name="pluginId">Plugin id.</param>
+        /// <param name="version">Plugin version.</param>
+        /// <response code="204">Plugin manifest returned.</response>
+        /// <response code="404">Plugin not found.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the plugin's manifest.
+        ///    The task result contains an <see cref="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/>
+        ///    when plugin not found.
+        /// </returns>
+        [HttpPost("{pluginId}/Manifest")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesFile(MediaTypeNames.Application.Json)]
+        public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
         {
-            return new MBRegistrationRecord
+            if (_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
             {
-                IsRegistered = true,
-                RegChecked = true,
-                TrialVersion = false,
-                IsValid = true,
-                RegError = false
-            };
+                return Ok(plugin!.Manifest);
+            }
+
+            return NotFound();
         }
 
         /// <summary>
-        /// Gets registration status for a feature.
+        /// Updates plugin security info.
         /// </summary>
-        /// <param name="name">Feature name.</param>
-        /// <response code="501">Not implemented.</response>
-        /// <returns>Not Implemented.</returns>
-        /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception>
-        [Obsolete("Paid plugins are not supported")]
-        [HttpGet("Registrations/{name}")]
-        [ProducesResponseType(StatusCodes.Status501NotImplemented)]
-        public ActionResult GetRegistration([FromRoute, Required] string name)
+        /// <param name="pluginSecurityInfo">Plugin security info.</param>
+        /// <response code="204">Plugin security info updated.</response>
+        /// <returns>An <see cref="NoContentResult"/>.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpPost("SecurityInfo")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult UpdatePluginSecurityInfo([FromBody, Required] PluginSecurityInfo pluginSecurityInfo)
         {
-            // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins,
-            // delete all these registration endpoints. They are only kept for compatibility.
-            throw new NotImplementedException();
+            return NoContent();
         }
     }
 }

+ 4 - 4
Jellyfin.Api/Models/ConfigurationPageInfo.cs

@@ -1,4 +1,4 @@
-using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Plugins;
 using MediaBrowser.Controller.Plugins;
 using MediaBrowser.Model.Plugins;
 
@@ -32,16 +32,16 @@ namespace Jellyfin.Api.Models
         /// </summary>
         /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param>
         /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param>
-        public ConfigurationPageInfo(IPlugin plugin, PluginPageInfo page)
+        public ConfigurationPageInfo(IPlugin? plugin, PluginPageInfo page)
         {
             Name = page.Name;
             EnableInMainMenu = page.EnableInMainMenu;
             MenuSection = page.MenuSection;
             MenuIcon = page.MenuIcon;
-            DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin.Name : page.DisplayName;
+            DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name ?? page.DisplayName : page.DisplayName;
 
             // Don't use "N" because it needs to match Plugin.Id
-            PluginId = plugin.Id.ToString();
+            PluginId = plugin?.Id.ToString();
         }
 
         /// <summary>

+ 28 - 14
MediaBrowser.Common/IApplicationHost.cs

@@ -2,11 +2,16 @@ using System;
 using System.Collections.Generic;
 using System.Reflection;
 using System.Threading.Tasks;
-using MediaBrowser.Common.Plugins;
-using Microsoft.Extensions.DependencyInjection;
 
 namespace MediaBrowser.Common
 {
+    /// <summary>
+    /// Delegate used with GetExports{T}.
+    /// </summary>
+    /// <param name="type">Type to create.</param>
+    /// <returns>New instance of type <param>type</param>.</returns>
+    public delegate object CreationDelegate(Type type);
+
     /// <summary>
     /// An interface to be implemented by the applications hosting a kernel.
     /// </summary>
@@ -53,6 +58,11 @@ namespace MediaBrowser.Common
         /// <value>The application version.</value>
         Version ApplicationVersion { get; }
 
+        /// <summary>
+        /// Gets or sets the service provider.
+        /// </summary>
+        IServiceProvider ServiceProvider { get; set; }
+
         /// <summary>
         /// Gets the application version.
         /// </summary>
@@ -71,12 +81,6 @@ namespace MediaBrowser.Common
         /// </summary>
         string ApplicationUserAgentAddress { get; }
 
-        /// <summary>
-        /// Gets the plugins.
-        /// </summary>
-        /// <value>The plugins.</value>
-        IReadOnlyList<IPlugin> Plugins { get; }
-
         /// <summary>
         /// Gets all plugin assemblies which implement a custom rest api.
         /// </summary>
@@ -101,6 +105,22 @@ namespace MediaBrowser.Common
         /// <returns><see cref="IReadOnlyCollection{T}" />.</returns>
         IReadOnlyCollection<T> GetExports<T>(bool manageLifetime = true);
 
+        /// <summary>
+        /// Gets the exports.
+        /// </summary>
+        /// <typeparam name="T">The type.</typeparam>
+        /// <param name="defaultFunc">Delegate function that gets called to create the object.</param>
+        /// <param name="manageLifetime">If set to <c>true</c> [manage lifetime].</param>
+        /// <returns><see cref="IReadOnlyCollection{T}" />.</returns>
+        IReadOnlyCollection<T> GetExports<T>(CreationDelegate defaultFunc, bool manageLifetime = true);
+
+        /// <summary>
+        /// Gets the export types.
+        /// </summary>
+        /// <typeparam name="T">The type.</typeparam>
+        /// <returns>IEnumerable{Type}.</returns>
+        IEnumerable<Type> GetExportTypes<T>();
+
         /// <summary>
         /// Resolves this instance.
         /// </summary>
@@ -114,12 +134,6 @@ namespace MediaBrowser.Common
         /// <returns>A task.</returns>
         Task Shutdown();
 
-        /// <summary>
-        /// Removes the plugin.
-        /// </summary>
-        /// <param name="plugin">The plugin.</param>
-        void RemovePlugin(IPlugin plugin);
-
         /// <summary>
         /// Initializes this instance.
         /// </summary>

+ 6 - 9
MediaBrowser.Common/Plugins/BasePlugin.cs

@@ -7,7 +7,6 @@ using System.Runtime.InteropServices;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Model.Plugins;
 using MediaBrowser.Model.Serialization;
-using Microsoft.Extensions.DependencyInjection;
 
 namespace MediaBrowser.Common.Plugins
 {
@@ -64,14 +63,12 @@ namespace MediaBrowser.Common.Plugins
         /// <returns>PluginInfo.</returns>
         public virtual PluginInfo GetPluginInfo()
         {
-            var info = new PluginInfo
-            {
-                Name = Name,
-                Version = Version.ToString(),
-                Description = Description,
-                Id = Id.ToString(),
-                CanUninstall = CanUninstall
-            };
+            var info = new PluginInfo(
+                Name,
+                Version,
+                Description,
+                Id,
+                CanUninstall);
 
             return info;
         }

+ 33 - 0
MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs

@@ -0,0 +1,33 @@
+using System;
+using MediaBrowser.Model.Plugins;
+
+namespace MediaBrowser.Common.Plugins
+{
+    /// <summary>
+    /// Defines the <see cref="IHasPluginConfiguration" />.
+    /// </summary>
+    public interface IHasPluginConfiguration
+    {
+        /// <summary>
+        /// Gets the type of configuration this plugin uses.
+        /// </summary>
+        Type ConfigurationType { get; }
+
+        /// <summary>
+        /// Gets the plugin's configuration.
+        /// </summary>
+        BasePluginConfiguration Configuration { get; }
+
+        /// <summary>
+        /// Completely overwrites the current configuration with a new copy.
+        /// </summary>
+        /// <param name="configuration">The configuration.</param>
+        void UpdateConfiguration(BasePluginConfiguration configuration);
+
+        /// <summary>
+        /// Sets the startup directory creation function.
+        /// </summary>
+        /// <param name="directoryCreateFn">The directory function used to create the configuration folder.</param>
+        void SetStartupInfo(Action<string> directoryCreateFn);
+    }
+}

+ 3 - 37
MediaBrowser.Common/Plugins/IPlugin.cs

@@ -1,44 +1,36 @@
-#pragma warning disable CS1591
-
 using System;
 using MediaBrowser.Model.Plugins;
-using Microsoft.Extensions.DependencyInjection;
 
 namespace MediaBrowser.Common.Plugins
 {
     /// <summary>
-    /// Interface IPlugin.
+    /// Defines the <see cref="IPlugin" />.
     /// </summary>
     public interface IPlugin
     {
         /// <summary>
         /// Gets the name of the plugin.
         /// </summary>
-        /// <value>The name.</value>
         string Name { get; }
 
         /// <summary>
-        /// Gets the description.
+        /// Gets the Description.
         /// </summary>
-        /// <value>The description.</value>
         string Description { get; }
 
         /// <summary>
         /// Gets the unique id.
         /// </summary>
-        /// <value>The unique id.</value>
         Guid Id { get; }
 
         /// <summary>
         /// Gets the plugin version.
         /// </summary>
-        /// <value>The version.</value>
         Version Version { get; }
 
         /// <summary>
         /// Gets the path to the assembly file.
         /// </summary>
-        /// <value>The assembly file path.</value>
         string AssemblyFilePath { get; }
 
         /// <summary>
@@ -49,11 +41,10 @@ namespace MediaBrowser.Common.Plugins
         /// <summary>
         /// Gets the full path to the data folder, where the plugin can store any miscellaneous files needed.
         /// </summary>
-        /// <value>The data folder path.</value>
         string DataFolderPath { get; }
 
         /// <summary>
-        /// Gets the plugin info.
+        /// Gets the <see cref="PluginInfo"/>.
         /// </summary>
         /// <returns>PluginInfo.</returns>
         PluginInfo GetPluginInfo();
@@ -63,29 +54,4 @@ namespace MediaBrowser.Common.Plugins
         /// </summary>
         void OnUninstalling();
     }
-
-    public interface IHasPluginConfiguration
-    {
-        /// <summary>
-        /// Gets the type of configuration this plugin uses.
-        /// </summary>
-        /// <value>The type of the configuration.</value>
-        Type ConfigurationType { get; }
-
-        /// <summary>
-        /// Gets the plugin's configuration.
-        /// </summary>
-        /// <value>The configuration.</value>
-        BasePluginConfiguration Configuration { get; }
-
-        /// <summary>
-        /// Completely overwrites the current configuration with a new copy
-        /// Returns true or false indicating success or failure.
-        /// </summary>
-        /// <param name="configuration">The configuration.</param>
-        /// <exception cref="ArgumentNullException"><c>configuration</c> is <c>null</c>.</exception>
-        void UpdateConfiguration(BasePluginConfiguration configuration);
-
-        void SetStartupInfo(Action<string> directoryCreateFn);
-    }
 }

+ 86 - 0
MediaBrowser.Common/Plugins/IPluginManager.cs

@@ -0,0 +1,86 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MediaBrowser.Common.Plugins
+{
+    /// <summary>
+    /// Defines the <see cref="IPluginManager" />.
+    /// </summary>
+    public interface IPluginManager
+    {
+        /// <summary>
+        /// Gets the Plugins.
+        /// </summary>
+        IList<LocalPlugin> Plugins { get; }
+
+        /// <summary>
+        /// Creates the plugins.
+        /// </summary>
+        void CreatePlugins();
+
+        /// <summary>
+        /// Returns all the assemblies.
+        /// </summary>
+        /// <returns>An IEnumerable{Assembly}.</returns>
+        IEnumerable<Assembly> LoadAssemblies();
+
+        /// <summary>
+        /// Registers the plugin's services with the DI.
+        /// Note: DI is not yet instantiated yet.
+        /// </summary>
+        /// <param name="serviceCollection">A <see cref="ServiceCollection"/> instance.</param>
+        void RegisterServices(IServiceCollection serviceCollection);
+
+        /// <summary>
+        /// Saves the manifest back to disk.
+        /// </summary>
+        /// <param name="manifest">The <see cref="PluginManifest"/> to save.</param>
+        /// <param name="path">The path where to save the manifest.</param>
+        /// <returns>True if successful.</returns>
+        bool SaveManifest(PluginManifest manifest, string path);
+
+        /// <summary>
+        /// Imports plugin details from a folder.
+        /// </summary>
+        /// <param name="folder">Folder of the plugin.</param>
+        void ImportPluginFrom(string folder);
+
+        /// <summary>
+        /// Disable the plugin.
+        /// </summary>
+        /// <param name="assembly">The <see cref="Assembly"/> of the plug to disable.</param>
+        void FailPlugin(Assembly assembly);
+
+        /// <summary>
+        /// Disable the plugin.
+        /// </summary>
+        /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param>
+        void DisablePlugin(LocalPlugin plugin);
+
+        /// <summary>
+        /// Enables the plugin, disabling all other versions.
+        /// </summary>
+        /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param>
+        void EnablePlugin(LocalPlugin plugin);
+
+        /// <summary>
+        /// Attempts to find the plugin with and id of <paramref name="id"/>.
+        /// </summary>
+        /// <param name="id">Id of plugin.</param>
+        /// <param name="version">The version of the plugin to locate.</param>
+        /// <param name="plugin">A <see cref="LocalPlugin"/> if found, otherwise null.</param>
+        /// <returns>Boolean value signifying the success of the search.</returns>
+        bool TryGetPlugin(Guid id, Version? version, out LocalPlugin? plugin);
+
+        /// <summary>
+        /// Removes the plugin.
+        /// </summary>
+        /// <param name="plugin">The plugin.</param>
+        /// <returns>Outcome of the operation.</returns>
+        bool RemovePlugin(LocalPlugin plugin);
+    }
+}

+ 61 - 33
MediaBrowser.Common/Plugins/LocalPlugin.cs

@@ -1,6 +1,9 @@
+#nullable enable
 using System;
 using System.Collections.Generic;
 using System.Globalization;
+using System.Reflection;
+using MediaBrowser.Model.Plugins;
 
 namespace MediaBrowser.Common.Plugins
 {
@@ -9,36 +12,48 @@ namespace MediaBrowser.Common.Plugins
     /// </summary>
     public class LocalPlugin : IEquatable<LocalPlugin>
     {
+        private readonly bool _supported;
+        private Version? _version;
+
         /// <summary>
         /// Initializes a new instance of the <see cref="LocalPlugin"/> class.
         /// </summary>
-        /// <param name="id">The plugin id.</param>
-        /// <param name="name">The plugin name.</param>
-        /// <param name="version">The plugin version.</param>
         /// <param name="path">The plugin path.</param>
-        public LocalPlugin(Guid id, string name, Version version, string path)
+        /// <param name="isSupported"><b>True</b> if Jellyfin supports this version of the plugin.</param>
+        /// <param name="manifest">The manifest record for this plugin, or null if one does not exist.</param>
+        public LocalPlugin(string path, bool isSupported, PluginManifest manifest)
         {
-            Id = id;
-            Name = name;
-            Version = version;
             Path = path;
             DllFiles = new List<string>();
+            _supported = isSupported;
+            Manifest = manifest;
         }
 
         /// <summary>
         /// Gets the plugin id.
         /// </summary>
-        public Guid Id { get; }
+        public Guid Id => Manifest.Guid;
 
         /// <summary>
         /// Gets the plugin name.
         /// </summary>
-        public string Name { get; }
+        public string Name => Manifest.Name;
 
         /// <summary>
         /// Gets the plugin version.
         /// </summary>
-        public Version Version { get; }
+        public Version Version
+        {
+            get
+            {
+                if (_version == null)
+                {
+                    _version = Version.Parse(Manifest.Version);
+                }
+
+                return _version;
+            }
+        }
 
         /// <summary>
         /// Gets the plugin path.
@@ -51,26 +66,24 @@ namespace MediaBrowser.Common.Plugins
         public List<string> DllFiles { get; }
 
         /// <summary>
-        /// == operator.
+        /// Gets or sets the instance of this plugin.
         /// </summary>
-        /// <param name="left">Left item.</param>
-        /// <param name="right">Right item.</param>
-        /// <returns>Comparison result.</returns>
-        public static bool operator ==(LocalPlugin left, LocalPlugin right)
-        {
-            return left.Equals(right);
-        }
+        public IPlugin? Instance { get; set; }
 
         /// <summary>
-        /// != operator.
+        /// Gets a value indicating whether Jellyfin supports this version of the plugin, and it's enabled.
         /// </summary>
-        /// <param name="left">Left item.</param>
-        /// <param name="right">Right item.</param>
-        /// <returns>Comparison result.</returns>
-        public static bool operator !=(LocalPlugin left, LocalPlugin right)
-        {
-            return !left.Equals(right);
-        }
+        public bool IsEnabledAndSupported => _supported && Manifest.Status >= PluginStatus.Active;
+
+        /// <summary>
+        /// Gets a value indicating whether the plugin has a manifest.
+        /// </summary>
+        public PluginManifest Manifest { get; }
+
+        /// <summary>
+        /// Gets or sets a value indicating the assembly of the plugin.
+        /// </summary>
+        public Assembly? Assembly { get; set; }
 
         /// <summary>
         /// Compare two <see cref="LocalPlugin"/>.
@@ -80,10 +93,15 @@ namespace MediaBrowser.Common.Plugins
         /// <returns>Comparison result.</returns>
         public static int Compare(LocalPlugin a, LocalPlugin b)
         {
+            if (a == null || b == null)
+            {
+                throw new ArgumentNullException(a == null ? nameof(a) : nameof(b));
+            }
+
             var compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture);
 
             // Id is not equal but name is.
-            if (a.Id != b.Id && compare == 0)
+            if (!a.Id.Equals(b.Id) && compare == 0)
             {
                 compare = a.Id.CompareTo(b.Id);
             }
@@ -91,8 +109,20 @@ namespace MediaBrowser.Common.Plugins
             return compare == 0 ? a.Version.CompareTo(b.Version) : compare;
         }
 
+        /// <summary>
+        /// Returns the plugin information.
+        /// </summary>
+        /// <returns>A <see cref="PluginInfo"/> instance containing the information.</returns>
+        public PluginInfo GetPluginInfo()
+        {
+            var inst = Instance?.GetPluginInfo() ?? new PluginInfo(Manifest.Name, Version, Manifest.Description, Manifest.Guid, true);
+            inst.Status = Manifest.Status;
+            inst.HasImage = !string.IsNullOrEmpty(Manifest.ImageUrl);
+            return inst;
+        }
+
         /// <inheritdoc />
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             return obj is LocalPlugin other && this.Equals(other);
         }
@@ -104,16 +134,14 @@ namespace MediaBrowser.Common.Plugins
         }
 
         /// <inheritdoc />
-        public bool Equals(LocalPlugin other)
+        public bool Equals(LocalPlugin? other)
         {
-            // Do not use == or != for comparison as this class overrides the operators.
-            if (object.ReferenceEquals(other, null))
+            if (other == null)
             {
                 return false;
             }
 
-            return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase)
-                   && Id.Equals(other.Id);
+            return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && Id.Equals(other.Id) && Version.Equals(other.Version);
         }
     }
 }

+ 85 - 0
MediaBrowser.Common/Plugins/PluginManifest.cs

@@ -0,0 +1,85 @@
+#nullable enable
+using System;
+using MediaBrowser.Model.Plugins;
+
+namespace MediaBrowser.Common.Plugins
+{
+    /// <summary>
+    /// Defines a Plugin manifest file.
+    /// </summary>
+    public class PluginManifest
+    {
+        /// <summary>
+        /// Gets or sets the category of the plugin.
+        /// </summary>
+        public string Category { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the changelog information.
+        /// </summary>
+        public string Changelog { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the description of the plugin.
+        /// </summary>
+        public string Description { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the Global Unique Identifier for the plugin.
+        /// </summary>
+#pragma warning disable CA1720 // Identifier contains type name
+        public Guid Guid { get; set; }
+#pragma warning restore CA1720 // Identifier contains type name
+
+        /// <summary>
+        /// Gets or sets the Name of the plugin.
+        /// </summary>
+        public string Name { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets an overview of the plugin.
+        /// </summary>
+        public string Overview { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the owner of the plugin.
+        /// </summary>
+        public string Owner { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the compatibility version for the plugin.
+        /// </summary>
+        public string TargetAbi { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the upper compatibility version for the plugin.
+        /// </summary>
+        public string MaxAbi { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the timestamp of the plugin.
+        /// </summary>
+        public DateTime Timestamp { get; set; }
+
+        /// <summary>
+        /// Gets or sets the Version number of the plugin.
+        /// </summary>
+        public string Version { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this plugin should be ignored.
+        /// </summary>
+        public PluginStatus Status { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this plugin should automatically update.
+        /// </summary>
+        public bool AutoUpdate { get; set; } = true;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this plugin has an image.
+        /// Image must be located in the local plugin folder.
+        /// </summary>
+        public string? ImageUrl { get; set; }
+    }
+}

+ 20 - 12
MediaBrowser.Common/Updates/IInstallationManager.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#nullable enable
 
 using System;
 using System.Collections.Generic;
@@ -9,6 +9,9 @@ using MediaBrowser.Model.Updates;
 
 namespace MediaBrowser.Common.Updates
 {
+    /// <summary>
+    /// Defines the <see cref="IInstallationManager" />.
+    /// </summary>
     public interface IInstallationManager : IDisposable
     {
         /// <summary>
@@ -21,12 +24,13 @@ namespace MediaBrowser.Common.Updates
         /// </summary>
         /// <param name="manifestName">Name of the repository.</param>
         /// <param name="manifest">The URL to query.</param>
+        /// <param name="filterIncompatible">Filter out incompatible plugins.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
-        Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default);
+        Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, bool filterIncompatible, CancellationToken cancellationToken = default);
 
         /// <summary>
-        /// Gets all available packages.
+        /// Gets all available packages that are supported by this version.
         /// </summary>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
@@ -42,9 +46,11 @@ namespace MediaBrowser.Common.Updates
         /// <returns>All plugins matching the requirements.</returns>
         IEnumerable<PackageInfo> FilterPackages(
             IEnumerable<PackageInfo> availablePackages,
-            string name = null,
-            Guid guid = default,
-            Version specificVersion = null);
+            string? name = null,
+#pragma warning disable CA1720 // Identifier contains type name
+            Guid? guid = default,
+#pragma warning restore CA1720 // Identifier contains type name
+            Version? specificVersion = null);
 
         /// <summary>
         /// Returns all compatible versions ordered from newest to oldest.
@@ -57,13 +63,15 @@ namespace MediaBrowser.Common.Updates
         /// <returns>All compatible versions ordered from newest to oldest.</returns>
         IEnumerable<InstallationInfo> GetCompatibleVersions(
             IEnumerable<PackageInfo> availablePackages,
-            string name = null,
-            Guid guid = default,
-            Version minVersion = null,
-            Version specificVersion = null);
+            string? name = null,
+#pragma warning disable CA1720 // Identifier contains type name
+            Guid? guid = default,
+#pragma warning restore CA1720 // Identifier contains type name
+            Version? minVersion = null,
+            Version? specificVersion = null);
 
         /// <summary>
-        /// Returns the available plugin updates.
+        /// Returns the available compatible plugin updates.
         /// </summary>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The available plugin updates.</returns>
@@ -81,7 +89,7 @@ namespace MediaBrowser.Common.Updates
         /// Uninstalls a plugin.
         /// </summary>
         /// <param name="plugin">The plugin.</param>
-        void UninstallPlugin(IPlugin plugin);
+        void UninstallPlugin(LocalPlugin plugin);
 
         /// <summary>
         /// Cancels the installation.

+ 9 - 2
MediaBrowser.Common/Updates/InstallationEventArgs.cs

@@ -1,14 +1,21 @@
-#pragma warning disable CS1591
-
 using System;
 using MediaBrowser.Model.Updates;
 
 namespace MediaBrowser.Common.Updates
 {
+    /// <summary>
+    /// Defines the <see cref="InstallationEventArgs" />.
+    /// </summary>
     public class InstallationEventArgs : EventArgs
     {
+        /// <summary>
+        /// Gets or sets the <see cref="InstallationInfo"/>.
+        /// </summary>
         public InstallationInfo InstallationInfo { get; set; }
 
+        /// <summary>
+        /// Gets or sets the <see cref="VersionInfo"/>.
+        /// </summary>
         public VersionInfo VersionInfo { get; set; }
     }
 }

+ 4 - 3
MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs

@@ -1,18 +1,19 @@
-using Jellyfin.Data.Events;
+using Jellyfin.Data.Events;
 using MediaBrowser.Common.Plugins;
+using MediaBrowser.Model.Plugins;
 
 namespace MediaBrowser.Controller.Events.Updates
 {
     /// <summary>
     /// An event that occurs when a plugin is uninstalled.
     /// </summary>
-    public class PluginUninstalledEventArgs : GenericEventArgs<IPlugin>
+    public class PluginUninstalledEventArgs : GenericEventArgs<PluginInfo>
     {
         /// <summary>
         /// Initializes a new instance of the <see cref="PluginUninstalledEventArgs"/> class.
         /// </summary>
         /// <param name="arg">The plugin.</param>
-        public PluginUninstalledEventArgs(IPlugin arg) : base(arg)
+        public PluginUninstalledEventArgs(PluginInfo arg) : base(arg)
         {
         }
     }

+ 0 - 10
MediaBrowser.Controller/IServerApplicationHost.cs

@@ -19,8 +19,6 @@ namespace MediaBrowser.Controller
     {
         event EventHandler HasUpdateAvailableChanged;
 
-        IServiceProvider ServiceProvider { get; }
-
         bool CoreStartupHasCompleted { get; }
 
         bool CanLaunchWebBrowser { get; }
@@ -122,13 +120,5 @@ namespace MediaBrowser.Controller
         string ExpandVirtualPath(string path);
 
         string ReverseVirtualPath(string path);
-
-        /// <summary>
-        /// Gets the list of local plugins.
-        /// </summary>
-        /// <param name="path">Plugin base directory.</param>
-        /// <param name="cleanup">Cleanup old plugins.</param>
-        /// <returns>Enumerable of local plugins.</returns>
-        IEnumerable<LocalPlugin> GetLocalPlugins(string path, bool cleanup = true);
     }
 }

+ 10 - 0
MediaBrowser.Model/Configuration/ServerConfiguration.cs

@@ -449,5 +449,15 @@ namespace MediaBrowser.Model.Configuration
         /// Gets or sets the how many metadata refreshes can run concurrently.
         /// </summary>
         public int LibraryMetadataRefreshConcurrency { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder.
+        /// </summary>
+        public bool RemoveOldPlugins { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether plugin image should be disabled.
+        /// </summary>
+        public bool DisablePluginImages { get; set; }
     }
 }

+ 29 - 10
MediaBrowser.Model/Plugins/PluginInfo.cs

@@ -1,4 +1,7 @@
-#nullable disable
+#nullable enable
+
+using System;
+
 namespace MediaBrowser.Model.Plugins
 {
     /// <summary>
@@ -6,34 +9,46 @@ namespace MediaBrowser.Model.Plugins
     /// </summary>
     public class PluginInfo
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PluginInfo"/> class.
+        /// </summary>
+        /// <param name="name">The plugin name.</param>
+        /// <param name="version">The plugin <see cref="Version"/>.</param>
+        /// <param name="description">The plugin description.</param>
+        /// <param name="id">The <see cref="Guid"/>.</param>
+        /// <param name="canUninstall">True if this plugin can be uninstalled.</param>
+        public PluginInfo(string name, Version version, string description, Guid id, bool canUninstall)
+        {
+            Name = name;
+            Version = version?.ToString() ?? throw new ArgumentNullException(nameof(version));
+            Description = description;
+            Id = id.ToString();
+            CanUninstall = canUninstall;
+        }
+
         /// <summary>
         /// Gets or sets the name.
         /// </summary>
-        /// <value>The name.</value>
         public string Name { get; set; }
 
         /// <summary>
         /// Gets or sets the version.
         /// </summary>
-        /// <value>The version.</value>
         public string Version { get; set; }
 
         /// <summary>
         /// Gets or sets the name of the configuration file.
         /// </summary>
-        /// <value>The name of the configuration file.</value>
-        public string ConfigurationFileName { get; set; }
+        public string? ConfigurationFileName { get; set; }
 
         /// <summary>
         /// Gets or sets the description.
         /// </summary>
-        /// <value>The description.</value>
         public string Description { get; set; }
 
         /// <summary>
         /// Gets or sets the unique id.
         /// </summary>
-        /// <value>The unique id.</value>
         public string Id { get; set; }
 
         /// <summary>
@@ -42,9 +57,13 @@ namespace MediaBrowser.Model.Plugins
         public bool CanUninstall { get; set; }
 
         /// <summary>
-        /// Gets or sets the image URL.
+        /// Gets or sets a value indicating whether this plugin has a valid image.
+        /// </summary>
+        public bool HasImage { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating the status of the plugin.
         /// </summary>
-        /// <value>The image URL.</value>
-        public string ImageUrl { get; set; }
+        public PluginStatus Status { get; set; }
     }
 }

+ 17 - 0
MediaBrowser.Model/Plugins/PluginStatus.cs

@@ -0,0 +1,17 @@
+#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
+#pragma warning disable SA1602 // Enumeration items should be documented
+namespace MediaBrowser.Model.Plugins
+{
+    /// <summary>
+    /// Plugin load status.
+    /// </summary>
+    public enum PluginStatus
+    {
+        RestartRequired = 1,
+        Active = 0,
+        Disabled = -1,
+        NotSupported = -2,
+        Malfunction = -3,
+        Superceded = -4
+    }
+}

+ 29 - 14
MediaBrowser.Model/Updates/PackageInfo.cs

@@ -1,4 +1,4 @@
-#nullable disable
+#nullable enable
 using System;
 using System.Collections.Generic;
 
@@ -9,55 +9,70 @@ namespace MediaBrowser.Model.Updates
     /// </summary>
     public class PackageInfo
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PackageInfo"/> class.
+        /// </summary>
+        public PackageInfo()
+        {
+            Versions = Array.Empty<VersionInfo>();
+            Guid = string.Empty;
+            Category = string.Empty;
+            Name = string.Empty;
+            Overview = string.Empty;
+            Owner = string.Empty;
+            Description = string.Empty;
+        }
+
         /// <summary>
         /// Gets or sets the name.
         /// </summary>
         /// <value>The name.</value>
-        public string name { get; set; }
+        public string Name { get; set; }
 
         /// <summary>
         /// Gets or sets a long description of the plugin containing features or helpful explanations.
         /// </summary>
         /// <value>The description.</value>
-        public string description { get; set; }
+        public string Description { get; set; }
 
         /// <summary>
         /// Gets or sets a short overview of what the plugin does.
         /// </summary>
         /// <value>The overview.</value>
-        public string overview { get; set; }
+        public string Overview { get; set; }
 
         /// <summary>
         /// Gets or sets the owner.
         /// </summary>
         /// <value>The owner.</value>
-        public string owner { get; set; }
+        public string Owner { get; set; }
 
         /// <summary>
         /// Gets or sets the category.
         /// </summary>
         /// <value>The category.</value>
-        public string category { get; set; }
+        public string Category { get; set; }
 
         /// <summary>
-        /// The guid of the assembly associated with this plugin.
+        /// Gets or sets the guid of the assembly associated with this plugin.
         /// This is used to identify the proper item for automatic updates.
         /// </summary>
         /// <value>The name.</value>
-        public string guid { get; set; }
+#pragma warning disable CA1720 // Identifier contains type name
+        public string Guid { get; set; }
+#pragma warning restore CA1720 // Identifier contains type name
 
         /// <summary>
         /// Gets or sets the versions.
         /// </summary>
         /// <value>The versions.</value>
-        public IList<VersionInfo> versions { get; set; }
+#pragma warning disable CA2227 // Collection properties should be read only
+        public IList<VersionInfo> Versions { get; set; }
+#pragma warning restore CA2227 // Collection properties should be read only
 
         /// <summary>
-        /// Initializes a new instance of the <see cref="PackageInfo"/> class.
+        /// Gets or sets the image url for the package.
         /// </summary>
-        public PackageInfo()
-        {
-            versions = Array.Empty<VersionInfo>();
-        }
+        public string? ImageUrl { get; set; }
     }
 }

+ 21 - 21
MediaBrowser.Model/Updates/VersionInfo.cs

@@ -1,6 +1,6 @@
-#nullable disable
+#nullable enable
 
-using System;
+using SysVersion = System.Version;
 
 namespace MediaBrowser.Model.Updates
 {
@@ -9,68 +9,68 @@ namespace MediaBrowser.Model.Updates
     /// </summary>
     public class VersionInfo
     {
-        private Version _version;
+        private SysVersion? _version;
 
         /// <summary>
         /// Gets or sets the version.
         /// </summary>
         /// <value>The version.</value>
-        public string version
+        public string Version
         {
-            get
-            {
-                return _version == null ? string.Empty : _version.ToString();
-            }
+            get => _version == null ? string.Empty : _version.ToString();
 
-            set
-            {
-                _version = Version.Parse(value);
-            }
+            set => _version = SysVersion.Parse(value);
         }
 
         /// <summary>
-        /// Gets the version as a <see cref="Version"/>.
+        /// Gets the version as a <see cref="SysVersion"/>.
         /// </summary>
-        public Version VersionNumber => _version;
+        public SysVersion VersionNumber => _version ?? new SysVersion(0, 0, 0);
 
         /// <summary>
         /// Gets or sets the changelog for this version.
         /// </summary>
         /// <value>The changelog.</value>
-        public string changelog { get; set; }
+        public string? Changelog { get; set; }
 
         /// <summary>
         /// Gets or sets the ABI that this version was built against.
         /// </summary>
         /// <value>The target ABI version.</value>
-        public string targetAbi { get; set; }
+        public string? TargetAbi { get; set; }
+
+        /// <summary>
+        /// Gets or sets the maximum ABI that this version will work with.
+        /// </summary>
+        /// <value>The target ABI version.</value>
+        public string? MaxAbi { get; set; }
 
         /// <summary>
         /// Gets or sets the source URL.
         /// </summary>
         /// <value>The source URL.</value>
-        public string sourceUrl { get; set; }
+        public string? SourceUrl { get; set; }
 
         /// <summary>
         /// Gets or sets a checksum for the binary.
         /// </summary>
         /// <value>The checksum.</value>
-        public string checksum { get; set; }
+        public string? Checksum { get; set; }
 
         /// <summary>
         /// Gets or sets a timestamp of when the binary was built.
         /// </summary>
         /// <value>The timestamp.</value>
-        public string timestamp { get; set; }
+        public string? Timestamp { get; set; }
 
         /// <summary>
         /// Gets or sets the repository name.
         /// </summary>
-        public string repositoryName { get; set; }
+        public string RepositoryName { get; set; } = string.Empty;
 
         /// <summary>
         /// Gets or sets the repository url.
         /// </summary>
-        public string repositoryUrl { get; set; }
+        public string RepositoryUrl { get; set; } = string.Empty;
     }
 }

+ 3 - 2
MediaBrowser.sln

@@ -1,4 +1,5 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+
+Microsoft Visual Studio Solution File, Format Version 12.00
 # Visual Studio Version 16
 VisualStudioVersion = 16.0.30503.244
 MinimumVisualStudioVersion = 10.0.40219.1
@@ -70,7 +71,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking", "Jell
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking.Tests", "tests\Jellyfin.Networking.Tests\NetworkTesting\Jellyfin.Networking.Tests.csproj", "{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Dlna.Tests", "tests\Jellyfin.Dlna.Tests\Jellyfin.Dlna.Tests.csproj", "{B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Dlna.Tests", "tests\Jellyfin.Dlna.Tests\Jellyfin.Dlna.Tests.csproj", "{B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution