using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; 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 AssemblyLoadContext _assemblyLoadContext; private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger _logger; private readonly IApplicationHost _appHost; private readonly ServerConfiguration _config; private readonly List _plugins; private readonly Version _minimumVersion; private IHttpClientFactory? _httpClientFactory; /// /// 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 = new JsonSerializerOptions(JsonDefaults.Options) { 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(); _assemblyLoadContext = new AssemblyLoadContext("PluginContext", true); } private IHttpClientFactory HttpClientFactory { get { return _httpClientFactory ??= _appHost.Resolve(); } } /// /// Gets the Plugins. /// public IReadOnlyList 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 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 = _assemblyLoadContext.LoadFromAssemblyPath(file); // Load all required types to verify that the plugin will load assembly.GetTypes(); } catch (FileLoadException ex) { _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file); ChangePluginState(plugin, PluginStatus.Malfunctioned); continue; } catch (SystemException ex) when (ex is TypeLoadException or ReflectionTypeLoadException) // Undocumented exception { _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references an incompatible version of one of the shared libraries. Disabling plugin.", file); ChangePluginState(plugin, PluginStatus.NotSupported); continue; } #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, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin.", file); ChangePluginState(plugin, PluginStatus.Malfunctioned); continue; } _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, file); yield return assembly; } } } /// public void UnloadAssemblies() { _assemblyLoadContext.Unload(); } /// /// Creates all the plugin instances. /// public void CreatePlugins() { _ = _appHost.GetExports(CreatePluginInstance); } /// /// 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 = GetPluginByAssembly(pluginServiceRegistrator.Assembly); if (plugin is 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) { ArgumentException.ThrowIfNullOrEmpty(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) { ArgumentNullException.ThrowIfNull(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; } /// /// 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 is 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 is not null) ?? 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) { ArgumentNullException.ThrowIfNull(plugin); if (ChangePluginState(plugin, PluginStatus.Active)) { // See if there is another version, and if so, supercede it. ProcessAlternative(plugin); } } /// /// Disable the plugin. /// /// The of the plug to disable. public void DisablePlugin(LocalPlugin plugin) { ArgumentNullException.ThrowIfNull(plugin); // Update the manifest on disk if (ChangePluginState(plugin, PluginStatus.Disabled)) { // If there is another version, activate it. ProcessAlternative(plugin); } } /// /// Disable the plugin. /// /// The of the plug to disable. public void FailPlugin(Assembly assembly) { // Only save if disabled. ArgumentNullException.ThrowIfNull(assembly); var plugin = _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location)); if (plugin is null) { // A plugin's assembly didn't cause this issue, so ignore it. return; } ChangePluginState(plugin, PluginStatus.Malfunctioned); } /// public bool SaveManifest(PluginManifest manifest, string path) { try { var data = JsonSerializer.Serialize(manifest, _jsonOptions); File.WriteAllText(Path.Combine(path, "meta.json"), data); return true; } catch (ArgumentException e) { _logger.LogWarning(e, "Unable to save plugin manifest due to invalid value. {Path}", path); return false; } } /// public async Task GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) { var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); var imagePath = string.Empty; if (!string.IsNullOrEmpty(packageInfo.ImageUrl)) { var url = new Uri(packageInfo.ImageUrl); imagePath = Path.Join(path, url.Segments[^1]); await using var fileStream = AsyncFile.OpenWrite(imagePath); try { await using var downloadStream = await HttpClientFactory .CreateClient(NamedClient.Default) .GetStreamAsync(url) .ConfigureAwait(false); await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); } catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); imagePath = string.Empty; } } var manifest = new PluginManifest { Category = packageInfo.Category, Changelog = versionInfo.Changelog ?? string.Empty, Description = packageInfo.Description, Id = packageInfo.Id, Name = packageInfo.Name, Overview = packageInfo.Overview, Owner = packageInfo.Owner, TargetAbi = versionInfo.TargetAbi ?? string.Empty, Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture), Version = versionInfo.Version, Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state. AutoUpdate = true, ImagePath = imagePath }; return SaveManifest(manifest, path); } /// /// 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 assembly. /// /// The being sought. /// The matching record, or null if not found. private LocalPlugin? GetPluginByAssembly(Assembly assembly) { // Find which plugin it is by the path. return _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location, StringComparer.Ordinal)); } /// /// Creates the instance safe. /// /// The type. /// System.Object. 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); // _appHost.ServiceProvider is already assigned when we create the plugins var instance = (IPlugin)ActivatorUtilities.CreateInstance(_appHost.ServiceProvider!, type); if (plugin is null) { // Create a dummy record for the providers. // TODO: remove this code once 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 = instance.Version.ToString(); bool changed = false; if (string.Equals(manifest.Version, pluginStr, StringComparison.Ordinal) || !manifest.Id.Equals(instance.Id)) { // If a plugin without a manifest failed to load due to an external issue (eg config), // this updates the manifest to the actual plugin values. manifest.Version = pluginStr; manifest.Name = plugin.Instance.Name; manifest.Description = plugin.Instance.Description; manifest.Id = plugin.Instance.Id; 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 is not 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 is not 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); } internal LocalPlugin LoadManifest(string dir) { Version? version; PluginManifest? manifest = null; var metafile = Path.Combine(dir, "meta.json"); if (File.Exists(metafile)) { // Only path where this stays null is when File.ReadAllBytes throws an IOException byte[] data = null!; try { data = File.ReadAllBytes(metafile); manifest = JsonSerializer.Deserialize(data, _jsonOptions); } catch (IOException ex) { _logger.LogError(ex, "Error reading file {Path}.", dir); } catch (JsonException ex) { _logger.LogError(ex, "Error deserializing {Json}.", Encoding.UTF8.GetString(data!)); } if (manifest is not 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]); 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.Active, 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 = Directory.GetFiles(entry.Path, "*.dll", SearchOption.AllDirectories); if (entry.IsEnabledAndSupported) { lastName = entry.Name; continue; } } if (string.IsNullOrEmpty(lastName)) { continue; } 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 { 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 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 is null) { // This value is memory only - so that the web will show restart required. plugin.Manifest.Status = PluginStatus.Restart; plugin.Manifest.AutoUpdate = false; 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; plugin.Manifest.AutoUpdate = false; } } }