#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 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 { /// /// Defines the . /// public class PluginManager : IPluginManager { private readonly string _pluginsPath; private readonly Version _appVersion; private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger _logger; private readonly IApplicationHost _appHost; private readonly ServerConfiguration _config; private readonly IList _plugins; private readonly Version _minimumVersion; /// /// Initializes a new instance of the class. /// /// The . /// The . /// The . /// The plugin path. /// The application version. public PluginManager( ILogger 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 = JsonDefaults.GetOptions(); _jsonOptions.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(); } /// /// Gets the Plugins. /// public IList Plugins => _plugins; /// /// Returns all the assemblies. /// /// An IEnumerable{Assembly}. public IEnumerable LoadAssemblies() { // Attempt to remove any deleted plugins and change any successors to be active. for (int a = _plugins.Count - 1; a >= 0; a--) { var plugin = _plugins[a]; if (plugin.Manifest.Status == PluginStatus.Deleted && DeletePlugin(plugin)) { UpdateSuccessors(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); } 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; } } } /// /// Creates all the plugin instances. /// public void CreatePlugins() { _ = _appHost.GetExports(CreatePluginInstance) .Where(i => i != null) .ToArray(); } /// /// Registers the plugin's services with the DI. /// Note: DI is not yet instantiated yet. /// /// A instance. public void RegisterServices(IServiceCollection serviceCollection) { foreach (var pluginServiceRegistrator in _appHost.GetExportTypes()) { var plugin = GetPluginByType(pluginServiceRegistrator.Assembly.GetType()); 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); } } } } /// /// Imports a plugin manifest from . /// /// Folder of the plugin. 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); } /// /// Removes the plugin reference '. /// /// The plugin. /// Outcome of the operation. public bool RemovePlugin(LocalPlugin plugin) { if (plugin == null) { throw new ArgumentNullException(nameof(plugin)); } if (DeletePlugin(plugin)) { return true; } _logger.LogWarning("Unable to delete {Path}, so marking as deleteOnStartup.", plugin.Path); // Unable to delete, so disable. return ChangePluginState(plugin, PluginStatus.Deleted); } /// /// Attempts to find the plugin with and id of . /// /// The of plugin. /// Optional of the plugin to locate. /// A if located, or null if not. 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)); 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; } /// /// Enables the plugin, disabling all other versions. /// /// The of the plug to disable. public void EnablePlugin(LocalPlugin plugin) { if (plugin == null) { throw new ArgumentNullException(nameof(plugin)); } if (ChangePluginState(plugin, PluginStatus.Active)) { UpdateSuccessors(plugin); } } /// /// Disable the plugin. /// /// The of the plug to disable. public void DisablePlugin(LocalPlugin plugin) { if (plugin == null) { throw new ArgumentNullException(nameof(plugin)); } // Update the manifest on disk if (ChangePluginState(plugin, PluginStatus.Disabled)) { UpdateSuccessors(plugin); } } /// /// Disable the plugin. /// /// The of the plug to disable. 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); } /// /// Saves the manifest back to disk. /// /// The to save. /// The path where to save the manifest. /// True if successful. 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; } } /// /// Changes a plugin's load status. /// /// The instance. /// The of the plugin. /// Success of the task. 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); } /// /// Finds the plugin record using the type. /// /// The being sought. /// The matching record, or null if not found. private LocalPlugin? GetPluginByType(Type type) { // Find which plugin it is by the path. return _plugins.FirstOrDefault(p => string.Equals(p.Path, Path.GetDirectoryName(type.Assembly.Location), StringComparison.Ordinal)); } /// /// Creates the instance safe. /// /// The type. /// System.Object. private IPlugin? CreatePluginInstance(Type type) { // Find the record for this plugin. var plugin = GetPluginByType(type); 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; } /// /// Attempts to delete a plugin. /// /// A instance to delete. /// True if successful. 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(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); } /// /// Gets the list of local plugins. /// /// Enumerable of local plugins. private IEnumerable DiscoverPlugins() { var versions = new List(); if (!Directory.Exists(_pluginsPath)) { // Plugin path doesn't exist, don't try to enumerate sub-folders. return Enumerable.Empty(); } 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); } /// /// Changes the status of the other versions of the plugin to "Superceded". /// /// The that's master. private void UpdateSuccessors(LocalPlugin plugin) { // This value is memory only - so that the web will show restart required. plugin.Manifest.Status = PluginStatus.Restart; // Detect whether there is another version of this plugin that needs disabling. var predecessor = _plugins.OrderByDescending(p => p.Version) .FirstOrDefault( p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); if (predecessor == null) { return; } if (predecessor.Manifest.Status == PluginStatus.Active && !ChangePluginState(predecessor, PluginStatus.Superceded)) { _logger.LogError("Unable to disable version {Version} of {Name}", predecessor.Version, predecessor.Name); } } } }