diff options
| author | Joshua M. Boniface <joshua@boniface.me> | 2020-12-31 18:47:05 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-12-31 18:47:05 -0500 |
| commit | 406ae3e43a20216292c554151fa2d0e2a09edfa3 (patch) | |
| tree | 288ceeb9831470d5f573fccc6619f23b259c5531 | |
| parent | e006cc8ac32ac470a203f60dfd49cbe3acc999e7 (diff) | |
| parent | bd1c115e46795f7db38366d31de79bf2ff88ca8d (diff) | |
Merge pull request #4709 from BaronGreenback/PluginDowngrade
35 files changed, 1973 insertions, 950 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 8fa712914..1b9bb86bb 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -120,7 +120,9 @@ namespace Emby.Server.Implementations private readonly IFileSystem _fileSystemManager; private readonly IXmlSerializer _xmlSerializer; private readonly IStartupOptions _startupOptions; + private readonly IPluginManager _pluginManager; + private List<Type> _creatingInstances; private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; private string[] _urlPrefixes; @@ -183,16 +185,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(_fileSystemManager); @@ -767,34 +808,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(); @@ -833,22 +847,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) @@ -861,11 +859,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; } @@ -1028,130 +1028,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 jsonString = File.ReadAllText(metafile, Encoding.UTF8); - var manifest = JsonSerializer.Deserialize<PluginManifest>(jsonString, _jsonOptions); - - 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 @@ -1393,17 +1278,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 diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 592873fe4..67f23f055 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -65,5 +65,4 @@ <EmbeddedResource Include="Localization\Core\*.json" /> <EmbeddedResource Include="Localization\Ratings\*.csv" /> </ItemGroup> - </Project> diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs new file mode 100644 index 000000000..1ab01252d --- /dev/null +++ b/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; + } + } +} diff --git a/Emby.Server.Implementations/Plugins/PluginManifest.cs b/Emby.Server.Implementations/Plugins/PluginManifest.cs deleted file mode 100644 index 33762791b..000000000 --- a/Emby.Server.Implementations/Plugins/PluginManifest.cs +++ /dev/null @@ -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; } - } -} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index 161fa0580..a69380cbb 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/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 { diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index ae2fa3ce1..abcb4313f 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/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; } } } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index ccc81dfc5..b77d79209 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/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); } } } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 6295dfc05..9ab8e0bdc 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/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(); } diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 98f1bc2d2..b73611c97 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/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(); @@ -77,6 +193,40 @@ namespace Jellyfin.Api.Controllers } /// <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> /// <param name="pluginId">Plugin id.</param> @@ -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(); - } } } diff --git a/Jellyfin.Api/Models/ConfigurationPageInfo.cs b/Jellyfin.Api/Models/ConfigurationPageInfo.cs index 2aa6373aa..f56ef5976 100644 --- a/Jellyfin.Api/Models/ConfigurationPageInfo.cs +++ b/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; } } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index cd594b5c5..bbfc4fbd4 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/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) diff --git a/MediaBrowser.Common/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 849037ac4..ddcf2ac17 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -2,12 +2,17 @@ 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> public interface IApplicationHost @@ -54,6 +59,11 @@ namespace MediaBrowser.Common Version ApplicationVersion { get; } /// <summary> + /// Gets or sets the service provider. + /// </summary> + IServiceProvider ServiceProvider { get; set; } + + /// <summary> /// Gets the application version. /// </summary> /// <value>The application version.</value> @@ -72,12 +82,6 @@ namespace MediaBrowser.Common 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> /// <returns>An <see cref="IEnumerable{Assembly}"/> containing the plugin assemblies.</returns> @@ -102,6 +106,22 @@ namespace MediaBrowser.Common 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> /// <typeparam name="T">The <c>Type</c>.</typeparam> @@ -115,12 +135,6 @@ namespace MediaBrowser.Common Task Shutdown(); /// <summary> - /// Removes the plugin. - /// </summary> - /// <param name="plugin">The plugin.</param> - void RemovePlugin(IPlugin plugin); - - /// <summary> /// Initializes this instance. /// </summary> void Init(); diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index 2ef24a884..9a2ea6875 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/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(), diff --git a/MediaBrowser.Common/Plugins/BasePlugin.cs b/MediaBrowser.Common/Plugins/BasePlugin.cs index 084e91d50..e228ae7ec 100644 --- a/MediaBrowser.Common/Plugins/BasePlugin.cs +++ b/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; - } - } } diff --git a/MediaBrowser.Common/Plugins/BasePluginOfT.cs b/MediaBrowser.Common/Plugins/BasePluginOfT.cs new file mode 100644 index 000000000..d5c780851 --- /dev/null +++ b/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; + } + } + } +} diff --git a/MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs b/MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs new file mode 100644 index 000000000..af9272caa --- /dev/null +++ b/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); + } +} diff --git a/MediaBrowser.Common/Plugins/IPlugin.cs b/MediaBrowser.Common/Plugins/IPlugin.cs index d583a5887..b2ba1179c 100644 --- a/MediaBrowser.Common/Plugins/IPlugin.cs +++ b/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); - } } diff --git a/MediaBrowser.Common/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs new file mode 100644 index 000000000..3da34d8bb --- /dev/null +++ b/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); + } +} diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs index c97e75a3b..23b6cfa81 100644 --- a/MediaBrowser.Common/Plugins/LocalPlugin.cs +++ b/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); } } } diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs new file mode 100644 index 000000000..4c724f694 --- /dev/null +++ b/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; } + } +} diff --git a/MediaBrowser.Common/Updates/IInstallationManager.cs b/MediaBrowser.Common/Updates/IInstallationManager.cs index 585b1ee19..0844c2d79 100644 --- a/MediaBrowser.Common/Updates/IInstallationManager.cs +++ b/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. diff --git a/MediaBrowser.Common/Updates/InstallationEventArgs.cs b/MediaBrowser.Common/Updates/InstallationEventArgs.cs index 61178f631..adf336313 100644 --- a/MediaBrowser.Common/Updates/InstallationEventArgs.cs +++ b/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; } } } diff --git a/MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs b/MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs index 7510b62b8..a111e6d82 100644 --- a/MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs +++ b/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) { } } diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index 2456da826..92b2d43ce 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/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); } } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 9fb978e9b..0f0ad0f9a 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/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; } } } diff --git a/MediaBrowser.Model/Plugins/PluginInfo.cs b/MediaBrowser.Model/Plugins/PluginInfo.cs index dd215192f..25216610d 100644 --- a/MediaBrowser.Model/Plugins/PluginInfo.cs +++ b/MediaBrowser.Model/Plugins/PluginInfo.cs @@ -1,4 +1,7 @@ -#nullable disable +#nullable enable + +using System; + namespace MediaBrowser.Model.Plugins { /// <summary> @@ -7,34 +10,46 @@ namespace MediaBrowser.Model.Plugins 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; } } } diff --git a/MediaBrowser.Model/Plugins/PluginPageInfo.cs b/MediaBrowser.Model/Plugins/PluginPageInfo.cs index ca72e19ee..85c0aa204 100644 --- a/MediaBrowser.Model/Plugins/PluginPageInfo.cs +++ b/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; } } } diff --git a/MediaBrowser.Model/Plugins/PluginStatus.cs b/MediaBrowser.Model/Plugins/PluginStatus.cs new file mode 100644 index 000000000..4b9b9bbee --- /dev/null +++ b/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 + } +} diff --git a/MediaBrowser.Model/Updates/InstallationInfo.cs b/MediaBrowser.Model/Updates/InstallationInfo.cs index a6d80dba6..eebe1a903 100644 --- a/MediaBrowser.Model/Updates/InstallationInfo.cs +++ b/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. diff --git a/MediaBrowser.Model/Updates/PackageInfo.cs b/MediaBrowser.Model/Updates/PackageInfo.cs index 5e9304363..7a82685f0 100644 --- a/MediaBrowser.Model/Updates/PackageInfo.cs +++ b/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 { @@ -10,54 +11,75 @@ namespace MediaBrowser.Model.Updates 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; } } } diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs index 844170999..209092265 100644 --- a/MediaBrowser.Model/Updates/VersionInfo.cs +++ b/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; } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs index b5bd72ff0..ba0d7b569 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index 90266e440..43bd3a472 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs index 41ca56164..d7f6781e5 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.sln b/MediaBrowser.sln index 5a807372d..c654e8ef3 100644 --- a/MediaBrowser.sln +++ b/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 |
