2
0
Эх сурвалжийг харах

Merge pull request #4709 from BaronGreenback/PluginDowngrade

(cherry picked from commit 406ae3e43a20216292c554151fa2d0e2a09edfa3)
Signed-off-by: Joshua M. Boniface <joshua@boniface.me>
Joshua M. Boniface 4 жил өмнө
parent
commit
1ad8e54035
35 өөрчлөгдсөн 1973 нэмэгдсэн , 949 устгасан
  1. 63 188
      Emby.Server.Implementations/ApplicationHost.cs
  2. 0 1
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  3. 688 0
      Emby.Server.Implementations/Plugins/PluginManager.cs
  4. 0 60
      Emby.Server.Implementations/Plugins/PluginManifest.cs
  5. 1 1
      Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
  6. 237 216
      Emby.Server.Implementations/Updates/InstallationManager.cs
  7. 12 8
      Jellyfin.Api/Controllers/DashboardController.cs
  8. 2 1
      Jellyfin.Api/Controllers/PackageController.cs
  9. 220 73
      Jellyfin.Api/Controllers/PluginsController.cs
  10. 7 9
      Jellyfin.Api/Models/ConfigurationPageInfo.cs
  11. 1 0
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  12. 28 14
      MediaBrowser.Common/IApplicationHost.cs
  13. 1 0
      MediaBrowser.Common/Json/JsonDefaults.cs
  14. 6 214
      MediaBrowser.Common/Plugins/BasePlugin.cs
  15. 208 0
      MediaBrowser.Common/Plugins/BasePluginOfT.cs
  16. 27 0
      MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs
  17. 3 37
      MediaBrowser.Common/Plugins/IPlugin.cs
  18. 86 0
      MediaBrowser.Common/Plugins/IPluginManager.cs
  19. 56 35
      MediaBrowser.Common/Plugins/LocalPlugin.cs
  20. 110 0
      MediaBrowser.Common/Plugins/PluginManifest.cs
  21. 18 14
      MediaBrowser.Common/Updates/IInstallationManager.cs
  22. 9 2
      MediaBrowser.Common/Updates/InstallationEventArgs.cs
  23. 4 3
      MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs
  24. 0 10
      MediaBrowser.Controller/IServerApplicationHost.cs
  25. 10 0
      MediaBrowser.Model/Configuration/ServerConfiguration.cs
  26. 31 12
      MediaBrowser.Model/Plugins/PluginInfo.cs
  27. 27 7
      MediaBrowser.Model/Plugins/PluginPageInfo.cs
  28. 47 0
      MediaBrowser.Model/Plugins/PluginStatus.cs
  29. 5 3
      MediaBrowser.Model/Updates/InstallationInfo.cs
  30. 36 14
      MediaBrowser.Model/Updates/PackageInfo.cs
  31. 25 22
      MediaBrowser.Model/Updates/VersionInfo.cs
  32. 1 1
      MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs
  33. 1 1
      MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs
  34. 1 1
      MediaBrowser.Providers/Plugins/Omdb/Plugin.cs
  35. 2 2
      MediaBrowser.sln

+ 63 - 188
Emby.Server.Implementations/ApplicationHost.cs

@@ -120,7 +120,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;
@@ -182,16 +184,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>
@@ -288,6 +280,13 @@ namespace Emby.Server.Implementations
             ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version;
             ApplicationVersionString = ApplicationVersion.ToString(3);
             ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
+
+            _pluginManager = new PluginManager(
+                LoggerFactory.CreateLogger<PluginManager>(),
+                this,
+                ServerConfigurationManager.Configuration,
+                ApplicationPaths.PluginsPath,
+                ApplicationVersion);
         }
 
         /// <summary>
@@ -387,16 +386,41 @@ 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 in the attempted creation of {Type}", type.FullName);
+                foreach (var entry in _creatingInstances)
+                {
+                    Logger.LogError("Called from: {TypeName}", entry.FullName);
+                }
+
+                _pluginManager.FailPlugin(type.Assembly);
+
+                throw new ExternalException("DI Loop detected.");
+            }
+
             try
             {
+                _creatingInstances.Add(type);
                 Logger.LogDebug("Creating instance of {Type}", type);
                 return ActivatorUtilities.CreateInstance(ServiceProvider, type);
             }
             catch (Exception ex)
             {
                 Logger.LogError(ex, "Error creating {Type}", type);
+                // If this is a plugin fail it.
+                _pluginManager.FailPlugin(type.Assembly);
                 return null;
             }
+            finally
+            {
+                _creatingInstances.Remove(type);
+            }
         }
 
         /// <summary>
@@ -406,11 +430,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);
@@ -439,6 +459,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>
@@ -511,7 +552,7 @@ namespace Emby.Server.Implementations
 
             RegisterServices();
 
-            RegisterPluginServices();
+            _pluginManager.RegisterServices(ServiceCollection);
         }
 
         /// <summary>
@@ -525,7 +566,7 @@ namespace Emby.Server.Implementations
 
             ServiceCollection.AddSingleton(ConfigurationManager);
             ServiceCollection.AddSingleton<IApplicationHost>(this);
-
+            ServiceCollection.AddSingleton<IPluginManager>(_pluginManager);
             ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
 
             ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>();
@@ -770,34 +811,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();
 
@@ -836,22 +850,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)
@@ -864,11 +862,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;
                 }
 
@@ -1031,129 +1031,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
@@ -1395,17 +1281,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

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

@@ -74,5 +74,4 @@
     <EmbeddedResource Include="Localization\Core\*.json" />
     <EmbeddedResource Include="Localization\Ratings\*.csv" />
   </ItemGroup>
-
 </Project>

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

@@ -0,0 +1,688 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Common.Json.Converters;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Plugins;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Plugins
+{
+    /// <summary>
+    /// Defines the <see cref="PluginManager" />.
+    /// </summary>
+    public class PluginManager : IPluginManager
+    {
+        private readonly string _pluginsPath;
+        private readonly Version _appVersion;
+        private readonly JsonSerializerOptions _jsonOptions;
+        private readonly ILogger<PluginManager> _logger;
+        private readonly IApplicationHost _appHost;
+        private readonly ServerConfiguration _config;
+        private readonly IList<LocalPlugin> _plugins;
+        private readonly Version _minimumVersion;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PluginManager"/> class.
+        /// </summary>
+        /// <param name="logger">The <see cref="ILogger"/>.</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="appVersion">The application version.</param>
+        public PluginManager(
+            ILogger<PluginManager> logger,
+            IApplicationHost appHost,
+            ServerConfiguration config,
+            string pluginsPath,
+            Version appVersion)
+        {
+            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+            _pluginsPath = pluginsPath;
+            _appVersion = appVersion ?? throw new ArgumentNullException(nameof(appVersion));
+            _jsonOptions = new JsonSerializerOptions(JsonDefaults.GetOptions())
+            {
+                WriteIndented = true
+            };
+
+            // We need to use the default GUID converter, so we need to remove any custom ones.
+            for (int a = _jsonOptions.Converters.Count - 1; a >= 0; a--)
+            {
+                if (_jsonOptions.Converters[a] is JsonGuidConverter convertor)
+                {
+                    _jsonOptions.Converters.Remove(convertor);
+                    break;
+                }
+            }
+
+            _config = config;
+            _appHost = appHost;
+            _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()
+        {
+            // Attempt to remove any deleted plugins and change any successors to be active.
+            for (int i = _plugins.Count - 1; i >= 0; i--)
+            {
+                var plugin = _plugins[i];
+                if (plugin.Manifest.Status == PluginStatus.Deleted && DeletePlugin(plugin))
+                {
+                    // See if there is another version, and if so make that active.
+                    ProcessAlternative(plugin);
+                }
+            }
+
+            // Now load the assemblies..
+            foreach (var plugin in _plugins)
+            {
+                UpdatePluginSuperceedStatus(plugin);
+
+                if (plugin.IsEnabledAndSupported == false)
+                {
+                    _logger.LogInformation("Skipping disabled plugin {Version} of {Name} ", plugin.Version, plugin.Name);
+                    continue;
+                }
+
+                foreach (var file in plugin.DllFiles)
+                {
+                    Assembly assembly;
+                    try
+                    {
+                        assembly = Assembly.LoadFrom(file);
+
+                        // This force loads all reference dll's that the plugin uses in the try..catch block.
+                        // Removing this will cause JF to bomb out if referenced dll's cause issues.
+                        assembly.GetExportedTypes();
+                    }
+                    catch (FileLoadException ex)
+                    {
+                        _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file);
+                        ChangePluginState(plugin, PluginStatus.Malfunctioned);
+                        continue;
+                    }
+
+                    _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, file);
+                    yield return assembly;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Creates all the plugin instances.
+        /// </summary>
+        public void CreatePlugins()
+        {
+            _ = _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 = GetPluginByAssembly(pluginServiceRegistrator.Assembly);
+                if (plugin == null)
+                {
+                    _logger.LogError("Unable to find plugin in assembly {Assembly}", pluginServiceRegistrator.Assembly.FullName);
+                    continue;
+                }
+
+                UpdatePluginSuperceedStatus(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.Malfunctioned))
+                    {
+                        _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 (_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));
+            }
+
+            if (DeletePlugin(plugin))
+            {
+                ProcessAlternative(plugin);
+                return true;
+            }
+
+            _logger.LogWarning("Unable to delete {Path}, so marking as deleteOnStartup.", plugin.Path);
+            // Unable to delete, so disable.
+            if (ChangePluginState(plugin, PluginStatus.Deleted))
+            {
+                ProcessAlternative(plugin);
+                return true;
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Attempts to find the plugin with and id of <paramref name="id"/>.
+        /// </summary>
+        /// <param name="id">The <see cref="Guid"/> of plugin.</param>
+        /// <param name="version">Optional <see cref="Version"/> of the plugin to locate.</param>
+        /// <returns>A <see cref="LocalPlugin"/> if located, or null if not.</returns>
+        public LocalPlugin? GetPlugin(Guid id, Version? version = null)
+        {
+            LocalPlugin? plugin;
+
+            if (version == null)
+            {
+                // If no version is given, return the current instance.
+                var plugins = _plugins.Where(p => p.Id.Equals(id)).ToList();
+
+                plugin = plugins.FirstOrDefault(p => p.Instance != null);
+                if (plugin == null)
+                {
+                    plugin = plugins.OrderByDescending(p => p.Version).FirstOrDefault();
+                }
+            }
+            else
+            {
+                // Match id and version number.
+                plugin = _plugins.FirstOrDefault(p => p.Id.Equals(id) && p.Version.Equals(version));
+            }
+
+            return plugin;
+        }
+
+        /// <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))
+            {
+                // See if there is another version, and if so, supercede it.
+                ProcessAlternative(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))
+            {
+                // If there is another version, activate it.
+                ProcessAlternative(plugin);
+            }
+        }
+
+        /// <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.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location));
+            if (plugin == null)
+            {
+                // A plugin's assembly didn't cause this issue, so ignore it.
+                return;
+            }
+
+            ChangePluginState(plugin, PluginStatus.Malfunctioned);
+        }
+
+        /// <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;
+            return SaveManifest(plugin.Manifest, plugin.Path);
+        }
+
+        /// <summary>
+        /// Finds the plugin record using the assembly.
+        /// </summary>
+        /// <param name="assembly">The <see cref="Assembly"/> being sought.</param>
+        /// <returns>The matching record, or null if not found.</returns>
+        private LocalPlugin? GetPluginByAssembly(Assembly assembly)
+        {
+            // Find which plugin it is by the path.
+            return _plugins.FirstOrDefault(p => string.Equals(p.Path, Path.GetDirectoryName(assembly.Location), StringComparison.Ordinal));
+        }
+
+        /// <summary>
+        /// Creates the instance safe.
+        /// </summary>
+        /// <param name="type">The type.</param>
+        /// <returns>System.Object.</returns>
+        private IPlugin? CreatePluginInstance(Type type)
+        {
+            // Find the record for this plugin.
+            var plugin = GetPluginByAssembly(type.Assembly);
+            if (plugin?.Manifest.Status < PluginStatus.Active)
+            {
+                return null;
+            }
+
+            try
+            {
+                _logger.LogDebug("Creating instance of {Type}", type);
+                var instance = (IPlugin)ActivatorUtilities.CreateInstance(_appHost.ServiceProvider, type);
+                if (plugin == null)
+                {
+                    // Create a dummy record for the providers.
+                    // TODO: remove this code, if all provided have been released as separate plugins.
+                    plugin = new LocalPlugin(
+                        instance.AssemblyFilePath,
+                        true,
+                        new PluginManifest
+                        {
+                            Id = instance.Id,
+                            Status = PluginStatus.Active,
+                            Name = instance.Name,
+                            Version = instance.Version.ToString()
+                        })
+                    {
+                        Instance = instance
+                    };
+
+                    _plugins.Add(plugin);
+
+                    plugin.Manifest.Status = PluginStatus.Active;
+                }
+                else
+                {
+                    plugin.Instance = instance;
+                    var manifest = plugin.Manifest;
+                    var pluginStr = plugin.Instance.Version.ToString();
+                    bool changed = false;
+                    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;
+                        changed = true;
+                    }
+
+                    changed = changed || manifest.Status != PluginStatus.Active;
+                    manifest.Status = PluginStatus.Active;
+
+                    if (changed)
+                    {
+                        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.Malfunctioned))
+                    {
+                        _logger.LogInformation("Plugin {Path} has been disabled.", plugin.Path);
+                        return null;
+                    }
+                }
+
+                _logger.LogDebug("Unable to auto-disable.");
+                return null;
+            }
+        }
+
+        private void UpdatePluginSuperceedStatus(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
+            {
+                Directory.Delete(plugin.Path, true);
+                _logger.LogDebug("Deleted {Path}", plugin.Path);
+            }
+#pragma warning disable CA1031 // Do not catch general exception types
+            catch
+#pragma warning restore CA1031 // Do not catch general exception types
+            {
+                return false;
+            }
+
+            return _plugins.Remove(plugin);
+        }
+
+        private LocalPlugin LoadManifest(string dir)
+        {
+            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.Version, out version))
+                {
+                    manifest.Version = _minimumVersion.ToString();
+                }
+
+                return new LocalPlugin(dir, _appVersion >= targetAbi, 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.
+            manifest = new PluginManifest
+            {
+                Status = PluginStatus.Restart,
+                Name = metafile,
+                AutoUpdate = false,
+                Id = metafile.GetMD5(),
+                TargetAbi = _appVersion.ToString(),
+                Version = version.ToString()
+            };
+
+            return new LocalPlugin(dir, true, manifest);
+        }
+
+        /// <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);
+            foreach (var dir in directories)
+            {
+                versions.Add(LoadManifest(dir));
+            }
+
+            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--)
+            {
+                var 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);
+                    }
+
+                    if (cleaned)
+                    {
+                        versions.RemoveAt(x);
+                    }
+                    else
+                    {
+                        if (manifest == null)
+                        {
+                            _logger.LogWarning("Unable to disable plugin {Path}", entry.Path);
+                            continue;
+                        }
+
+                        ChangePluginState(entry, PluginStatus.Deleted);
+                    }
+                }
+            }
+
+            // Only want plugin folders which have files.
+            return versions.Where(p => p.DllFiles.Count != 0);
+        }
+
+        /// <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 ProcessAlternative(LocalPlugin plugin)
+        {
+            // Detect whether there is another version of this plugin that needs disabling.
+            var previousVersion = _plugins.OrderByDescending(p => p.Version)
+                .FirstOrDefault(
+                    p => p.Id.Equals(plugin.Id)
+                    && p.IsEnabledAndSupported
+                    && p.Version != plugin.Version);
+
+            if (previousVersion == null)
+            {
+                // This value is memory only - so that the web will show restart required.
+                plugin.Manifest.Status = PluginStatus.Restart;
+                return;
+            }
+
+            if (plugin.Manifest.Status == PluginStatus.Active && !ChangePluginState(previousVersion, PluginStatus.Superceded))
+            {
+                _logger.LogError("Unable to enable version {Version} of {Name}", previousVersion.Version, previousVersion.Name);
+            }
+            else if (plugin.Manifest.Status == PluginStatus.Superceded && !ChangePluginState(previousVersion, PluginStatus.Active))
+            {
+                _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name);
+            }
+
+            // This value is memory only - so that the web will show restart required.
+            plugin.Manifest.Status = PluginStatus.Restart;
+        }
+    }
+}

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

+ 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
 {

+ 237 - 216
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#nullable enable
 
 using System;
 using System.Collections.Concurrent;
@@ -40,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>
@@ -63,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,
@@ -70,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>();
@@ -82,38 +91,65 @@ namespace Emby.Server.Implementations.Updates
             _eventManager = eventManager;
             _httpClientFactory = httpClientFactory;
             _config = config;
-            _fileSystem = fileSystem;
             _zipClient = zipClient;
             _jsonSerializerOptions = JsonDefaults.GetOptions();
+            _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
             {
-                var packages = await _httpClientFactory.CreateClient(NamedClient.Default)
-                    .GetFromJsonAsync<List<PackageInfo>>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
+                List<PackageInfo>? packages = await _httpClientFactory.CreateClient(NamedClient.Default)
+                        .GetFromJsonAsync<List<PackageInfo>>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
+
                 if (packages == null)
                 {
                     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;
+                        }
+
+                        // Only show plugins that are greater than or equal to targetAbi.
+                        if (_applicationHost.ApplicationVersion >= targetAbi)
+                        {
+                            continue;
+                        }
+
+                        // Not compatible with this version so remove it.
+                        entry.Versions.Remove(ver);
                     }
                 }
 
                 return packages;
             }
+            catch (IOException ex)
+            {
+                _logger.LogError(ex, "Cannot locate the plugin manifest {Manifest}", manifest);
+                return Array.Empty<PackageInfo>();
+            }
             catch (JsonException ex)
             {
                 _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
@@ -131,85 +167,58 @@ 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.Id, out var packageGuid))
                         {
                             // Package doesn't have a valid GUID, skip.
                             continue;
                         }
 
-                        for (var i = package.versions.Count - 1; i >= 0; i--)
+                        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];
+
+                            var plugin = _pluginManager.GetPlugin(packageGuid, version.VersionNumber);
+                            // Update the manifests, if anything changes.
+                            if (plugin != null)
+                            {
+                                if (!string.Equals(plugin.Manifest.TargetAbi, version.TargetAbi, StringComparison.Ordinal))
+                                {
+                                    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(package.versions[i].targetAbi, out var targetAbi)
-                                && _applicationHost.ApplicationVersion < targetAbi)
+                            if (Version.TryParse(version.TargetAbi, out var targetAbi) && _applicationHost.ApplicationVersion < targetAbi)
                             {
-                                package.versions.RemoveAt(i);
+                                package.Versions.RemoveAt(i);
                             }
                         }
 
                         // Don't add a package that doesn't have any compatible versions.
-                        if (package.versions.Count == 0)
+                        if (package.Versions.Count == 0)
                         {
                             continue;
                         }
 
-                        var existing = FilterPackages(result, package.name, packageGuid).FirstOrDefault();
                         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
                         {
@@ -225,23 +234,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? id = 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)
+            if (id != Guid.Empty)
             {
-                availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
+                availablePackages = availablePackages.Where(x => Guid.Parse(x.Id) == id);
             }
 
             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;
@@ -250,12 +259,12 @@ 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? id = default,
+            Version? minVersion = null,
+            Version? specificVersion = null)
         {
-            var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
+            var package = FilterPackages(availablePackages, name, id, specificVersion).FirstOrDefault();
 
             // Package not found in repository
             if (package == null)
@@ -264,8 +273,8 @@ 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);
 
             if (specificVersion != null)
             {
@@ -280,12 +289,12 @@ namespace Emby.Server.Implementations.Updates
             {
                 yield return new InstallationInfo
                 {
-                    Changelog = v.changelog,
-                    Guid = new Guid(package.guid),
-                    Name = package.name,
+                    Changelog = v.Changelog,
+                    Id = new Guid(package.Id),
+                    Name = package.Name,
                     Version = v.VersionNumber,
-                    SourceUrl = v.sourceUrl,
-                    Checksum = v.checksum
+                    SourceUrl = v.SourceUrl,
+                    Checksum = v.Checksum
                 };
             }
         }
@@ -297,20 +306,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)
         {
@@ -388,24 +383,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 {PluginName}, 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.Id == 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.Id != version.Id))
+                {
+                    yield return version;
+                }
+            }
         }
 
         private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken)
@@ -450,7 +561,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.
                 }
@@ -458,119 +571,27 @@ namespace Emby.Server.Implementations.Updates
 
             stream.Position = 0;
             _zipClient.ExtractAllFromZip(stream, targetDir, true);
-
-#pragma warning restore CA5351
-        }
-
-        /// <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();
+            _pluginManager.ImportPluginFrom(targetDir);
         }
 
-        /// <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.Id) && 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: {PluginName} {PluginVersion}" : "Plugin updated: {PluginName} {PluginVersion}", 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);
         }
     }
 }

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

@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Updates;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Model.Updates;
@@ -99,7 +100,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();
             }
 

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

@@ -1,15 +1,21 @@
 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.Model.Configuration;
+using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Plugins;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -23,22 +29,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>
@@ -50,23 +115,74 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         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>
-        /// Uninstalls a plugin.
+        /// 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 plugin could not be found.</returns>
+        [HttpPost("{pluginId}/{version}/Enable")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
+        {
+            var plugin = _pluginManager.GetPlugin(pluginId, version);
+            if (plugin == null)
+            {
+                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 plugin could not be found.</returns>
+        [HttpPost("{pluginId}/{version}/Disable")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
+        {
+            var plugin = _pluginManager.GetPlugin(pluginId, version);
+            if (plugin == null)
+            {
+                return NotFound();
+            }
+
+            _pluginManager.DisablePlugin(plugin);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Uninstalls a plugin by version.
+        /// </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>
-        [HttpDelete("{pluginId}")]
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+        [HttpDelete("{pluginId}/{version}")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
+        public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
         {
-            var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
+            var plugin = _pluginManager.GetPlugin(pluginId, version);
             if (plugin == null)
             {
                 return NotFound();
@@ -76,6 +192,40 @@ namespace Jellyfin.Api.Controllers
             return NoContent();
         }
 
+        /// <summary>
+        /// Uninstalls a plugin.
+        /// </summary>
+        /// <param name="pluginId">Plugin id.</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 plugin could not be found.</returns>
+        [HttpDelete("{pluginId}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [Obsolete("Please use the UninstallPluginByVersion API.")]
+        public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
+        {
+            // If no version is given, return the current instance.
+            var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId));
+
+            // Select the un-instanced one first.
+            var plugin = plugins.FirstOrDefault(p => p.Instance == null);
+            if (plugin == null)
+            {
+                // Then by the status.
+                plugin = plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault();
+            }
+
+            if (plugin != null)
+            {
+                _installationManager.UninstallPlugin(plugin);
+                return NoContent();
+            }
+
+            return NotFound();
+        }
+
         /// <summary>
         /// Gets plugin configuration.
         /// </summary>
@@ -88,12 +238,13 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId)
         {
-            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            var plugin = _pluginManager.GetPlugin(pluginId);
+            if (plugin?.Instance is IHasPluginConfiguration configPlugin)
             {
-                return NotFound();
+                return configPlugin.Configuration;
             }
 
-            return plugin.Configuration;
+            return NotFound();
         }
 
         /// <summary>
@@ -105,47 +256,81 @@ namespace Jellyfin.Api.Controllers
         /// <param name="pluginId">Plugin id.</param>
         /// <response code="204">Plugin configuration updated.</response>
         /// <response code="404">Plugin not found or plugin does not have configuration.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration.
-        ///    The task result contains an <see cref="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/>
-        ///    when plugin not found or plugin doesn't have configuration.
-        /// </returns>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
         [HttpPost("{pluginId}/Configuration")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId)
         {
-            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            var plugin = _pluginManager.GetPlugin(pluginId);
+            if (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}/{version}/Image")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo()
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        [AllowAnonymous]
+        public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
         {
-            return new PluginSecurityInfo
+            var plugin = _pluginManager.GetPlugin(pluginId, version);
+            if (plugin == null)
             {
-                IsMbSupporter = true,
-                SupporterKey = "IAmTotallyLegit"
-            };
+                return NotFound();
+            }
+
+            var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty);
+            if (((ServerConfiguration)_config.CommonConfiguration).DisablePluginImages
+                || plugin.Manifest.ImagePath == null
+                || !System.IO.File.Exists(imagePath))
+            {
+                return NotFound();
+            }
+
+            imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
+            return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath));
+        }
+
+        /// <summary>
+        /// Gets a plugin's manifest.
+        /// </summary>
+        /// <param name="pluginId">Plugin id.</param>
+        /// <response code="204">Plugin manifest returned.</response>
+        /// <response code="404">Plugin not found.</response>
+        /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+        [HttpPost("{pluginId}/Manifest")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId)
+        {
+            var plugin = _pluginManager.GetPlugin(pluginId);
+
+            if (plugin != null)
+            {
+                return plugin.Manifest;
+            }
+
+            return NotFound();
         }
 
         /// <summary>
@@ -162,43 +347,5 @@ namespace Jellyfin.Api.Controllers
         {
             return NoContent();
         }
-
-        /// <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 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 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();
-        }
     }
 }

+ 7 - 9
Jellyfin.Api/Models/ConfigurationPageInfo.cs

@@ -1,4 +1,5 @@
-using MediaBrowser.Common.Plugins;
+using System;
+using MediaBrowser.Common.Plugins;
 using MediaBrowser.Controller.Plugins;
 using MediaBrowser.Model.Plugins;
 
@@ -22,8 +23,7 @@ namespace Jellyfin.Api.Models
             if (page.Plugin != null)
             {
                 DisplayName = page.Plugin.Name;
-                // Don't use "N" because it needs to match Plugin.Id
-                PluginId = page.Plugin.Id.ToString();
+                PluginId = page.Plugin.Id;
             }
         }
 
@@ -32,16 +32,14 @@ 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;
-
-            // Don't use "N" because it needs to match Plugin.Id
-            PluginId = plugin.Id.ToString();
+            DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name : page.DisplayName;
+            PluginId = plugin?.Id;
         }
 
         /// <summary>
@@ -80,6 +78,6 @@ namespace Jellyfin.Api.Models
         /// Gets or sets the plugin id.
         /// </summary>
         /// <value>The plugin id.</value>
-        public string? PluginId { get; set; }
+        public Guid? PluginId { get; set; }
     }
 }

+ 1 - 0
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -227,6 +227,7 @@ namespace Jellyfin.Server.Extensions
                     options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented;
                     options.JsonSerializerOptions.DefaultIgnoreCondition = jsonOptions.DefaultIgnoreCondition;
                     options.JsonSerializerOptions.NumberHandling = jsonOptions.NumberHandling;
+                    options.JsonSerializerOptions.PropertyNameCaseInsensitive = jsonOptions.PropertyNameCaseInsensitive;
 
                     options.JsonSerializerOptions.Converters.Clear();
                     foreach (var converter in jsonOptions.Converters)

+ 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>

+ 1 - 0
MediaBrowser.Common/Json/JsonDefaults.cs

@@ -31,6 +31,7 @@ namespace MediaBrowser.Common.Json
             WriteIndented = false,
             DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
             NumberHandling = JsonNumberHandling.AllowReadingFromString,
+            PropertyNameCaseInsensitive = true,
             Converters =
             {
                 new JsonGuidConverter(),

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

@@ -1,5 +1,3 @@
-#pragma warning disable SA1402
-
 using System;
 using System.IO;
 using System.Reflection;
@@ -7,7 +5,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 +61,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;
         }
@@ -97,207 +92,4 @@ namespace MediaBrowser.Common.Plugins
             Id = assemblyId;
         }
     }
-
-    /// <summary>
-    /// Provides a common base class for all plugins.
-    /// </summary>
-    /// <typeparam name="TConfigurationType">The type of the T configuration type.</typeparam>
-    public abstract class BasePlugin<TConfigurationType> : BasePlugin, IHasPluginConfiguration
-        where TConfigurationType : BasePluginConfiguration
-    {
-        /// <summary>
-        /// The configuration sync lock.
-        /// </summary>
-        private readonly object _configurationSyncLock = new object();
-
-        /// <summary>
-        /// The configuration save lock.
-        /// </summary>
-        private readonly object _configurationSaveLock = new object();
-
-        private Action<string> _directoryCreateFn;
-
-        /// <summary>
-        /// The configuration.
-        /// </summary>
-        private TConfigurationType _configuration;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="BasePlugin{TConfigurationType}" /> class.
-        /// </summary>
-        /// <param name="applicationPaths">The application paths.</param>
-        /// <param name="xmlSerializer">The XML serializer.</param>
-        protected BasePlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
-        {
-            ApplicationPaths = applicationPaths;
-            XmlSerializer = xmlSerializer;
-            if (this is IPluginAssembly assemblyPlugin)
-            {
-                var assembly = GetType().Assembly;
-                var assemblyName = assembly.GetName();
-                var assemblyFilePath = assembly.Location;
-
-                var dataFolderPath = Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(assemblyFilePath));
-
-                assemblyPlugin.SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version);
-
-                var idAttributes = assembly.GetCustomAttributes(typeof(GuidAttribute), true);
-                if (idAttributes.Length > 0)
-                {
-                    var attribute = (GuidAttribute)idAttributes[0];
-                    var assemblyId = new Guid(attribute.Value);
-
-                    assemblyPlugin.SetId(assemblyId);
-                }
-            }
-
-            if (this is IHasPluginConfiguration hasPluginConfiguration)
-            {
-                hasPluginConfiguration.SetStartupInfo(s => Directory.CreateDirectory(s));
-            }
-        }
-
-        /// <summary>
-        /// Gets the application paths.
-        /// </summary>
-        /// <value>The application paths.</value>
-        protected IApplicationPaths ApplicationPaths { get; private set; }
-
-        /// <summary>
-        /// Gets the XML serializer.
-        /// </summary>
-        /// <value>The XML serializer.</value>
-        protected IXmlSerializer XmlSerializer { get; private set; }
-
-        /// <summary>
-        /// Gets the type of configuration this plugin uses.
-        /// </summary>
-        /// <value>The type of the configuration.</value>
-        public Type ConfigurationType => typeof(TConfigurationType);
-
-        /// <summary>
-        /// Gets or sets the event handler that is triggered when this configuration changes.
-        /// </summary>
-        public EventHandler<BasePluginConfiguration> ConfigurationChanged { get; set; }
-
-        /// <summary>
-        /// Gets the name the assembly file.
-        /// </summary>
-        /// <value>The name of the assembly file.</value>
-        protected string AssemblyFileName => Path.GetFileName(AssemblyFilePath);
-
-        /// <summary>
-        /// Gets or sets the plugin configuration.
-        /// </summary>
-        /// <value>The configuration.</value>
-        public TConfigurationType Configuration
-        {
-            get
-            {
-                // Lazy load
-                if (_configuration == null)
-                {
-                    lock (_configurationSyncLock)
-                    {
-                        if (_configuration == null)
-                        {
-                            _configuration = LoadConfiguration();
-                        }
-                    }
-                }
-
-                return _configuration;
-            }
-
-            protected set => _configuration = value;
-        }
-
-        /// <summary>
-        /// Gets the name of the configuration file. Subclasses should override.
-        /// </summary>
-        /// <value>The name of the configuration file.</value>
-        public virtual string ConfigurationFileName => Path.ChangeExtension(AssemblyFileName, ".xml");
-
-        /// <summary>
-        /// Gets the full path to the configuration file.
-        /// </summary>
-        /// <value>The configuration file path.</value>
-        public string ConfigurationFilePath => Path.Combine(ApplicationPaths.PluginConfigurationsPath, ConfigurationFileName);
-
-        /// <summary>
-        /// Gets the plugin configuration.
-        /// </summary>
-        /// <value>The configuration.</value>
-        BasePluginConfiguration IHasPluginConfiguration.Configuration => Configuration;
-
-        /// <inheritdoc />
-        public void SetStartupInfo(Action<string> directoryCreateFn)
-        {
-            // hack alert, until the .net core transition is complete
-            _directoryCreateFn = directoryCreateFn;
-        }
-
-        private TConfigurationType LoadConfiguration()
-        {
-            var path = ConfigurationFilePath;
-
-            try
-            {
-                return (TConfigurationType)XmlSerializer.DeserializeFromFile(typeof(TConfigurationType), path);
-            }
-            catch
-            {
-                var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
-                SaveConfiguration(config);
-                return config;
-            }
-        }
-
-        /// <summary>
-        /// Saves the current configuration to the file system.
-        /// </summary>
-        /// <param name="config">Configuration to save.</param>
-        public virtual void SaveConfiguration(TConfigurationType config)
-        {
-            lock (_configurationSaveLock)
-            {
-                _directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
-
-                XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
-            }
-        }
-
-        /// <summary>
-        /// Saves the current configuration to the file system.
-        /// </summary>
-        public virtual void SaveConfiguration()
-        {
-            SaveConfiguration(Configuration);
-        }
-
-        /// <inheritdoc />
-        public virtual void UpdateConfiguration(BasePluginConfiguration configuration)
-        {
-            if (configuration == null)
-            {
-                throw new ArgumentNullException(nameof(configuration));
-            }
-
-            Configuration = (TConfigurationType)configuration;
-
-            SaveConfiguration(Configuration);
-
-            ConfigurationChanged?.Invoke(this, configuration);
-        }
-
-        /// <inheritdoc />
-        public override PluginInfo GetPluginInfo()
-        {
-            var info = base.GetPluginInfo();
-
-            info.ConfigurationFileName = ConfigurationFileName;
-
-            return info;
-        }
-    }
 }

+ 208 - 0
MediaBrowser.Common/Plugins/BasePluginOfT.cs

@@ -0,0 +1,208 @@
+#pragma warning disable SA1649 // File name should match first type name
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Plugins;
+using MediaBrowser.Model.Serialization;
+
+namespace MediaBrowser.Common.Plugins
+{
+    /// <summary>
+    /// Provides a common base class for all plugins.
+    /// </summary>
+    /// <typeparam name="TConfigurationType">The type of the T configuration type.</typeparam>
+    public abstract class BasePlugin<TConfigurationType> : BasePlugin, IHasPluginConfiguration
+        where TConfigurationType : BasePluginConfiguration
+    {
+        /// <summary>
+        /// The configuration sync lock.
+        /// </summary>
+        private readonly object _configurationSyncLock = new object();
+
+        /// <summary>
+        /// The configuration save lock.
+        /// </summary>
+        private readonly object _configurationSaveLock = new object();
+
+        /// <summary>
+        /// The configuration.
+        /// </summary>
+        private TConfigurationType _configuration;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="BasePlugin{TConfigurationType}" /> class.
+        /// </summary>
+        /// <param name="applicationPaths">The application paths.</param>
+        /// <param name="xmlSerializer">The XML serializer.</param>
+        protected BasePlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
+        {
+            ApplicationPaths = applicationPaths;
+            XmlSerializer = xmlSerializer;
+            if (this is IPluginAssembly assemblyPlugin)
+            {
+                var assembly = GetType().Assembly;
+                var assemblyName = assembly.GetName();
+                var assemblyFilePath = assembly.Location;
+
+                var dataFolderPath = Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(assemblyFilePath));
+                if (!Directory.Exists(dataFolderPath) && Version != null)
+                {
+                    // Try again with the version number appended to the folder name.
+                    dataFolderPath = dataFolderPath + "_" + Version.ToString();
+                }
+
+                assemblyPlugin.SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version);
+
+                var idAttributes = assembly.GetCustomAttributes(typeof(GuidAttribute), true);
+                if (idAttributes.Length > 0)
+                {
+                    var attribute = (GuidAttribute)idAttributes[0];
+                    var assemblyId = new Guid(attribute.Value);
+
+                    assemblyPlugin.SetId(assemblyId);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets the application paths.
+        /// </summary>
+        /// <value>The application paths.</value>
+        protected IApplicationPaths ApplicationPaths { get; private set; }
+
+        /// <summary>
+        /// Gets the XML serializer.
+        /// </summary>
+        /// <value>The XML serializer.</value>
+        protected IXmlSerializer XmlSerializer { get; private set; }
+
+        /// <summary>
+        /// Gets the type of configuration this plugin uses.
+        /// </summary>
+        /// <value>The type of the configuration.</value>
+        public Type ConfigurationType => typeof(TConfigurationType);
+
+        /// <summary>
+        /// Gets or sets the event handler that is triggered when this configuration changes.
+        /// </summary>
+        public EventHandler<BasePluginConfiguration> ConfigurationChanged { get; set; }
+
+        /// <summary>
+        /// Gets the name the assembly file.
+        /// </summary>
+        /// <value>The name of the assembly file.</value>
+        protected string AssemblyFileName => Path.GetFileName(AssemblyFilePath);
+
+        /// <summary>
+        /// Gets or sets the plugin configuration.
+        /// </summary>
+        /// <value>The configuration.</value>
+        public TConfigurationType Configuration
+        {
+            get
+            {
+                // Lazy load
+                if (_configuration == null)
+                {
+                    lock (_configurationSyncLock)
+                    {
+                        if (_configuration == null)
+                        {
+                            _configuration = LoadConfiguration();
+                        }
+                    }
+                }
+
+                return _configuration;
+            }
+
+            protected set => _configuration = value;
+        }
+
+        /// <summary>
+        /// Gets the name of the configuration file. Subclasses should override.
+        /// </summary>
+        /// <value>The name of the configuration file.</value>
+        public virtual string ConfigurationFileName => Path.ChangeExtension(AssemblyFileName, ".xml");
+
+        /// <summary>
+        /// Gets the full path to the configuration file.
+        /// </summary>
+        /// <value>The configuration file path.</value>
+        public string ConfigurationFilePath => Path.Combine(ApplicationPaths.PluginConfigurationsPath, ConfigurationFileName);
+
+        /// <summary>
+        /// Gets the plugin configuration.
+        /// </summary>
+        /// <value>The configuration.</value>
+        BasePluginConfiguration IHasPluginConfiguration.Configuration => Configuration;
+
+        /// <summary>
+        /// Saves the current configuration to the file system.
+        /// </summary>
+        /// <param name="config">Configuration to save.</param>
+        public virtual void SaveConfiguration(TConfigurationType config)
+        {
+            lock (_configurationSaveLock)
+            {
+                var folder = Path.GetDirectoryName(ConfigurationFilePath);
+                if (!Directory.Exists(folder))
+                {
+                    Directory.CreateDirectory(folder);
+                }
+
+                XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
+            }
+        }
+
+        /// <summary>
+        /// Saves the current configuration to the file system.
+        /// </summary>
+        public virtual void SaveConfiguration()
+        {
+            SaveConfiguration(Configuration);
+        }
+
+        /// <inheritdoc />
+        public virtual void UpdateConfiguration(BasePluginConfiguration configuration)
+        {
+            if (configuration == null)
+            {
+                throw new ArgumentNullException(nameof(configuration));
+            }
+
+            Configuration = (TConfigurationType)configuration;
+
+            SaveConfiguration(Configuration);
+
+            ConfigurationChanged?.Invoke(this, configuration);
+        }
+
+        /// <inheritdoc />
+        public override PluginInfo GetPluginInfo()
+        {
+            var info = base.GetPluginInfo();
+
+            info.ConfigurationFileName = ConfigurationFileName;
+
+            return info;
+        }
+
+        private TConfigurationType LoadConfiguration()
+        {
+            var path = ConfigurationFilePath;
+
+            try
+            {
+                return (TConfigurationType)XmlSerializer.DeserializeFromFile(typeof(TConfigurationType), path);
+            }
+            catch
+            {
+                var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
+                SaveConfiguration(config);
+                return config;
+            }
+        }
+    }
+}

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

@@ -0,0 +1,27 @@
+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);
+    }
+}

+ 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 System.Threading.Tasks;
+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>
+        /// <returns>A <see cref="LocalPlugin"/> if located, or null if not.</returns>
+        LocalPlugin? GetPlugin(Guid id, Version? version = null);
+
+        /// <summary>
+        /// Removes the plugin.
+        /// </summary>
+        /// <param name="plugin">The plugin.</param>
+        /// <returns>Outcome of the operation.</returns>
+        bool RemovePlugin(LocalPlugin plugin);
+    }
+}

+ 56 - 35
MediaBrowser.Common/Plugins/LocalPlugin.cs

@@ -1,6 +1,7 @@
+#nullable enable
 using System;
 using System.Collections.Generic;
-using System.Globalization;
+using MediaBrowser.Model.Plugins;
 
 namespace MediaBrowser.Common.Plugins
 {
@@ -9,36 +10,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.Id;
 
         /// <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 +64,19 @@ 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>
         /// Compare two <see cref="LocalPlugin"/>.
@@ -80,10 +86,15 @@ namespace MediaBrowser.Common.Plugins
         /// <returns>Comparison result.</returns>
         public static int Compare(LocalPlugin a, LocalPlugin b)
         {
-            var compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture);
+            if (a == null || b == null)
+            {
+                throw new ArgumentNullException(a == null ? nameof(a) : nameof(b));
+            }
+
+            var compare = string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
 
             // 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 +102,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.Id, true);
+            inst.Status = Manifest.Status;
+            inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath);
+            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 +127,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);
         }
     }
 }

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

@@ -0,0 +1,110 @@
+#nullable enable
+
+using System;
+using System.Text.Json.Serialization;
+using MediaBrowser.Model.Plugins;
+
+namespace MediaBrowser.Common.Plugins
+{
+    /// <summary>
+    /// Defines a Plugin manifest file.
+    /// </summary>
+    public class PluginManifest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PluginManifest"/> class.
+        /// </summary>
+        public PluginManifest()
+        {
+            Category = string.Empty;
+            Changelog = string.Empty;
+            Description = string.Empty;
+            Id = Guid.Empty;
+            Name = string.Empty;
+            Owner = string.Empty;
+            Overview = string.Empty;
+            TargetAbi = string.Empty;
+            Version = string.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets the category of the plugin.
+        /// </summary>
+        [JsonPropertyName("category")]
+        public string Category { get; set; }
+
+        /// <summary>
+        /// Gets or sets the changelog information.
+        /// </summary>
+        [JsonPropertyName("changelog")]
+        public string Changelog { get; set; }
+
+        /// <summary>
+        /// Gets or sets the description of the plugin.
+        /// </summary>
+        [JsonPropertyName("description")]
+        public string Description { get; set; }
+
+        /// <summary>
+        /// Gets or sets the Global Unique Identifier for the plugin.
+        /// </summary>
+        [JsonPropertyName("guid")]
+        public Guid Id { get; set; }
+
+        /// <summary>
+        /// Gets or sets the Name of the plugin.
+        /// </summary>
+        [JsonPropertyName("name")]
+        public string Name { get; set; }
+
+        /// <summary>
+        /// Gets or sets an overview of the plugin.
+        /// </summary>
+        [JsonPropertyName("overview")]
+        public string Overview { get; set; }
+
+        /// <summary>
+        /// Gets or sets the owner of the plugin.
+        /// </summary>
+        [JsonPropertyName("owner")]
+        public string Owner { get; set; }
+
+        /// <summary>
+        /// Gets or sets the compatibility version for the plugin.
+        /// </summary>
+        [JsonPropertyName("targetAbi")]
+        public string TargetAbi { get; set; }
+
+        /// <summary>
+        /// Gets or sets the timestamp of the plugin.
+        /// </summary>
+        [JsonPropertyName("timestamp")]
+        public DateTime Timestamp { get; set; }
+
+        /// <summary>
+        /// Gets or sets the Version number of the plugin.
+        /// </summary>
+        [JsonPropertyName("version")]
+        public string Version { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating the operational status of this plugin.
+        /// </summary>
+        [JsonPropertyName("status")]
+        public PluginStatus Status { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this plugin should automatically update.
+        /// </summary>
+        [JsonPropertyName("autoUpdate")]
+        public bool AutoUpdate { get; set; } = true; // DO NOT MOVE THIS INTO THE CONSTRUCTOR.
+
+        /// <summary>
+        /// Gets or sets the ImagePath
+        /// Gets or sets a value indicating whether this plugin has an image.
+        /// Image must be located in the local plugin folder.
+        /// </summary>
+        [JsonPropertyName("imagePath")]
+        public string? ImagePath { get; set; }
+    }
+}

+ 18 - 14
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>
@@ -37,33 +41,33 @@ namespace MediaBrowser.Common.Updates
         /// </summary>
         /// <param name="availablePackages">The available packages.</param>
         /// <param name="name">The name of the plugin.</param>
-        /// <param name="guid">The id of the plugin.</param>
+        /// <param name="id">The id of the plugin.</param>
         /// <param name="specificVersion">The version of the plugin.</param>
         /// <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,
+            Guid? id = default,
+            Version? specificVersion = null);
 
         /// <summary>
         /// Returns all compatible versions ordered from newest to oldest.
         /// </summary>
         /// <param name="availablePackages">The available packages.</param>
         /// <param name="name">The name.</param>
-        /// <param name="guid">The guid of the plugin.</param>
+        /// <param name="id">The id of the plugin.</param>
         /// <param name="minVersion">The minimum required version of the plugin.</param>
         /// <param name="specificVersion">The specific version of the plugin to install.</param>
         /// <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,
+            Guid? id = default,
+            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 +85,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

@@ -456,5 +456,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; }
     }
 }

+ 31 - 12
MediaBrowser.Model/Plugins/PluginInfo.cs

@@ -1,4 +1,7 @@
-#nullable disable
+#nullable enable
+
+using System;
+
 namespace MediaBrowser.Model.Plugins
 {
     /// <summary>
@@ -6,35 +9,47 @@ 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;
+            Description = description;
+            Id = id;
+            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; }
+        public Version 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; }
+        public Guid Id { get; set; }
 
         /// <summary>
         /// Gets or sets a value indicating whether the plugin can be uninstalled.
@@ -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; }
     }
 }

+ 27 - 7
MediaBrowser.Model/Plugins/PluginPageInfo.cs

@@ -1,20 +1,40 @@
-#nullable disable
-#pragma warning disable CS1591
+#nullable enable
 
 namespace MediaBrowser.Model.Plugins
 {
+    /// <summary>
+    /// Defines the <see cref="PluginPageInfo" />.
+    /// </summary>
     public class PluginPageInfo
     {
-        public string Name { get; set; }
+        /// <summary>
+        /// Gets or sets the name.
+        /// </summary>
+        public string Name { get; set; } = string.Empty;
 
-        public string DisplayName { get; set; }
+        /// <summary>
+        /// Gets or sets the display name.
+        /// </summary>
+        public string? DisplayName { get; set; }
 
-        public string EmbeddedResourcePath { get; set; }
+        /// <summary>
+        /// Gets or sets the resource path.
+        /// </summary>
+        public string EmbeddedResourcePath { get; set; } = string.Empty;
 
+        /// <summary>
+        /// Gets or sets a value indicating whether this plugin should appear in the main menu.
+        /// </summary>
         public bool EnableInMainMenu { get; set; }
 
-        public string MenuSection { get; set; }
+        /// <summary>
+        /// Gets or sets the menu section.
+        /// </summary>
+        public string? MenuSection { get; set; }
 
-        public string MenuIcon { get; set; }
+        /// <summary>
+        /// Gets or sets the menu icon.
+        /// </summary>
+        public string? MenuIcon { get; set; }
     }
 }

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

@@ -0,0 +1,47 @@
+namespace MediaBrowser.Model.Plugins
+{
+    /// <summary>
+    /// Plugin load status.
+    /// </summary>
+    public enum PluginStatus
+    {
+        /// <summary>
+        /// This plugin requires a restart in order for it to load. This is a memory only status.
+        /// The actual status of the plugin after reload is present in the manifest.
+        /// eg. A disabled plugin will still be active until the next restart, and so will have a memory status of Restart,
+        /// but a disk manifest status of Disabled.
+        /// </summary>
+        Restart = 1,
+
+        /// <summary>
+        /// This plugin is currently running.
+        /// </summary>
+        Active = 0,
+
+        /// <summary>
+        /// This plugin has been marked as disabled.
+        /// </summary>
+        Disabled = -1,
+
+        /// <summary>
+        /// This plugin does not meet the TargetAbi requirements.
+        /// </summary>
+        NotSupported = -2,
+
+        /// <summary>
+        /// This plugin caused an error when instantiated. (Either DI loop, or exception)
+        /// </summary>
+        Malfunctioned = -3,
+
+        /// <summary>
+        /// This plugin has been superceded by another version.
+        /// </summary>
+        Superceded = -4,
+
+        /// <summary>
+        /// An attempt to remove this plugin from disk will happen at every restart.
+        /// It will not be loaded, if unable to do so.
+        /// </summary>
+        Deleted = -5
+    }
+}

+ 5 - 3
MediaBrowser.Model/Updates/InstallationInfo.cs

@@ -1,5 +1,6 @@
 #nullable disable
 using System;
+using System.Text.Json.Serialization;
 
 namespace MediaBrowser.Model.Updates
 {
@@ -9,10 +10,11 @@ namespace MediaBrowser.Model.Updates
     public class InstallationInfo
     {
         /// <summary>
-        /// Gets or sets the guid.
+        /// Gets or sets the Id.
         /// </summary>
-        /// <value>The guid.</value>
-        public Guid Guid { get; set; }
+        /// <value>The Id.</value>
+        [JsonPropertyName("Guid")]
+        public Guid Id { get; set; }
 
         /// <summary>
         /// Gets or sets the name.

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

@@ -1,6 +1,7 @@
-#nullable disable
+#nullable enable
 using System;
 using System.Collections.Generic;
+using System.Text.Json.Serialization;
 
 namespace MediaBrowser.Model.Updates
 {
@@ -9,55 +10,76 @@ 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>();
+            Id = 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; }
+        [JsonPropertyName("name")]
+        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; }
+        [JsonPropertyName("description")]
+        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; }
+        [JsonPropertyName("overview")]
+        public string Overview { get; set; }
 
         /// <summary>
         /// Gets or sets the owner.
         /// </summary>
         /// <value>The owner.</value>
-        public string owner { get; set; }
+        [JsonPropertyName("owner")]
+        public string Owner { get; set; }
 
         /// <summary>
         /// Gets or sets the category.
         /// </summary>
         /// <value>The category.</value>
-        public string category { get; set; }
+        [JsonPropertyName("category")]
+        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; }
+        [JsonPropertyName("guid")]
+        public string Id { get; set; }
 
         /// <summary>
         /// Gets or sets the versions.
         /// </summary>
         /// <value>The versions.</value>
-        public IList<VersionInfo> versions { get; set; }
+        [JsonPropertyName("versions")]
+#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>();
-        }
+        [JsonPropertyName("imageUrl")]
+        public string? ImageUrl { get; set; }
     }
 }

+ 25 - 22
MediaBrowser.Model/Updates/VersionInfo.cs

@@ -1,76 +1,79 @@
-#nullable disable
+#nullable enable
 
-using System;
+using System.Text.Json.Serialization;
+using SysVersion = System.Version;
 
 namespace MediaBrowser.Model.Updates
 {
     /// <summary>
-    /// Class PackageVersionInfo.
+    /// Defines the <see cref="VersionInfo"/> class.
     /// </summary>
     public class VersionInfo
     {
-        private Version _version;
+        private SysVersion? _version;
 
         /// <summary>
         /// Gets or sets the version.
         /// </summary>
         /// <value>The version.</value>
-        public string version
+        [JsonPropertyName("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; }
+        [JsonPropertyName("changelog")]
+        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; }
+        [JsonPropertyName("targetAbi")]
+        public string? TargetAbi { get; set; }
 
         /// <summary>
         /// Gets or sets the source URL.
         /// </summary>
         /// <value>The source URL.</value>
-        public string sourceUrl { get; set; }
+        [JsonPropertyName("sourceUrl")]
+        public string? SourceUrl { get; set; }
 
         /// <summary>
         /// Gets or sets a checksum for the binary.
         /// </summary>
         /// <value>The checksum.</value>
-        public string checksum { get; set; }
+        [JsonPropertyName("checksum")]
+        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; }
+        [JsonPropertyName("timestamp")]
+        public string? Timestamp { get; set; }
 
         /// <summary>
         /// Gets or sets the repository name.
         /// </summary>
-        public string repositoryName { get; set; }
+        [JsonPropertyName("repositoryName")]
+        public string RepositoryName { get; set; } = string.Empty;
 
         /// <summary>
         /// Gets or sets the repository url.
         /// </summary>
-        public string repositoryUrl { get; set; }
+        [JsonPropertyName("repositoryUrl")]
+        public string RepositoryUrl { get; set; } = string.Empty;
     }
 }

+ 1 - 1
MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#pragma warning disable CS1591
 
 using System;
 using System.Collections.Generic;

+ 1 - 1
MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#pragma warning disable CS1591
 
 using System;
 using System.Collections.Generic;

+ 1 - 1
MediaBrowser.Providers/Plugins/Omdb/Plugin.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#pragma warning disable CS1591
 
 using System;
 using System.Collections.Generic;

+ 2 - 2
MediaBrowser.sln

@@ -1,4 +1,4 @@
-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 +70,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