#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.Plugins; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Plugins; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations { /// /// 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 _nextVersion; private readonly Version _minimumVersion; /// /// Initializes a new instance of the class. /// /// The . /// The . /// The . /// The plugin path. /// The image cache path. /// The application version. public PluginManager( ILoggerFactory loggerfactory, IApplicationHost appHost, ServerConfiguration config, string pluginsPath, string imagesPath, Version appVersion) { _logger = loggerfactory.CreateLogger(); _pluginsPath = pluginsPath; _appVersion = appVersion ?? throw new ArgumentNullException(nameof(appVersion)); _jsonOptions = JsonDefaults.GetCamelCaseOptions(); _config = config; _appHost = appHost; _imagesPath = imagesPath; _nextVersion = new Version(_appVersion.Major, _appVersion.Minor + 2, _appVersion.Build, _appVersion.Revision); _minimumVersion = new Version(0, 0, 0, 1); _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List(); } /// /// 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) { CheckIfStillSuperceded(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() { var createdPlugins = _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; } CheckIfStillSuperceded(plugin); if (!plugin.IsEnabledAndSupported) { continue; } try { var instance = (IPluginServiceRegistrator?)Activator.CreateInstance(pluginServiceRegistrator); instance?.RegisterServices(serviceCollection); } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception ex) #pragma warning restore CA1031 // Do not catch general exception types { _logger.LogError(ex, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator.Assembly.FullName); if (ChangePluginState(plugin, PluginStatus.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 (plugin == null || _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); } } private void CopyFiles(string source, string destination, bool overwrite, string searchPattern) { FileInfo[] files; try { files = new DirectoryInfo(source).GetFiles(searchPattern); } catch (Exception ex) { _logger.LogDebug(ex, "Error retrieving file list."); return; } foreach (FileInfo file in files) { try { file.CopyTo(Path.Combine(destination, file.Name), overwrite); } catch (Exception ex) { _logger.LogDebug(ex, "Error copying file {Name}", file.Name); } } } /// /// 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; } // migrate settings across from the last active version if they don't exist. CopyFiles(predecessor.Path, plugin.Path, false, "*.xml"); if (predecessor.Manifest.Status == PluginStatus.Active && !ChangePluginState(predecessor, PluginStatus.Superceded)) { _logger.LogError("Unable to disable version {Version} of {Name}", predecessor.Version, predecessor.Name); } } /// /// 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.Where(p => p.DllFiles.Contains(assembly.Location)).FirstOrDefault(); 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 object? 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. var pInstance = (IPlugin)instance; plugin = new LocalPlugin( pInstance.AssemblyFilePath, true, new PluginManifest { Guid = pInstance.Id, Status = PluginStatus.Active, Name = pInstance.Name, Version = pInstance.Version.ToString(), MaxAbi = _nextVersion.ToString() }) { Instance = pInstance }; _plugins.Add(plugin); plugin.Manifest.Status = PluginStatus.Active; } else { plugin.Instance = (IPlugin)instance; var manifest = plugin.Manifest; var pluginStr = plugin.Instance.Version.ToString(); 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 CheckIfStillSuperceded(LocalPlugin plugin) { if (plugin.Manifest.Status != PluginStatus.Superceded) { return; } var predecessor = _plugins.OrderByDescending(p => p.Version) .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); if (predecessor != null) { return; } plugin.Manifest.Status = PluginStatus.Active; } /// /// 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); _plugins.Remove(plugin); } #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) { try { Version? version; PluginManifest? manifest = null; var metafile = Path.Combine(dir, "meta.json"); if (File.Exists(metafile)) { try { var data = File.ReadAllText(metafile, Encoding.UTF8); manifest = JsonSerializer.Deserialize(data, _jsonOptions); } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception ex) #pragma warning restore CA1031 // Do not catch general exception types { _logger.LogError(ex, "Error deserializing {Path}.", dir); } } if (manifest != null) { if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) { targetAbi = _minimumVersion; } if (!Version.TryParse(manifest.MaxAbi, out var maxAbi)) { maxAbi = _appVersion; } if (!Version.TryParse(manifest.Version, out version)) { manifest.Version = _minimumVersion.ToString(); } return new LocalPlugin(dir, _appVersion >= targetAbi && _appVersion <= maxAbi, manifest); } // No metafile, so lets see if the folder is versioned. // TODO: Phase this support out in future versions. metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1]; int versionIndex = dir.LastIndexOf('_'); if (versionIndex != -1) { // Get the version number from the filename if possible. metafile = Path.GetFileName(dir[..versionIndex]) ?? dir[..versionIndex]; version = Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version? parsedVersion) ? parsedVersion : _appVersion; } else { // Un-versioned folder - Add it under the path name and version it suitable for this instance. version = _appVersion; } // Auto-create a plugin manifest, so we can disable it, if it fails to load. manifest = new PluginManifest { Status = PluginStatus.Restart, Name = metafile, AutoUpdate = false, Guid = metafile.GetMD5(), TargetAbi = _appVersion.ToString(), Version = version.ToString() }; return new LocalPlugin(dir, true, manifest); } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception ex) #pragma warning restore CA1031 // Do not catch general exception types { _logger.LogError(ex, "Something went wrong!"); return null; } } /// /// 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); LocalPlugin? entry; foreach (var dir in directories) { entry = LoadManifest(dir); if (entry != null) { versions.Add(entry); } } string lastName = string.Empty; versions.Sort(LocalPlugin.Compare); // Traverse backwards through the list. // The first item will be the latest version. for (int x = versions.Count - 1; x >= 0; x--) { entry = versions[x]; if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) { entry.DllFiles.AddRange(Directory.EnumerateFiles(entry.Path, "*.dll", SearchOption.AllDirectories)); if (entry.IsEnabledAndSupported) { lastName = entry.Name; continue; } } if (string.IsNullOrEmpty(lastName)) { continue; } var manifest = entry.Manifest; var cleaned = false; var path = entry.Path; if (_config.RemoveOldPlugins) { // Attempt a cleanup of old folders. try { _logger.LogDebug("Deleting {Path}", path); Directory.Delete(path, true); cleaned = true; } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception e) #pragma warning restore CA1031 // Do not catch general exception types { _logger.LogWarning(e, "Unable to delete {Path}", path); } if (cleaned) { versions.RemoveAt(x); } else { if (manifest == null) { _logger.LogWarning("Unable to disable plugin {Path}", entry.Path); continue; } if (manifest.Status != PluginStatus.Deleted) { manifest.Status = PluginStatus.Deleted; SaveManifest(manifest, entry.Path); } } } } // Only want plugin folders which have files. return versions.Where(p => p.DllFiles.Count != 0); } } }