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.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; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api.Controllers { /// /// Plugins controller. /// [Authorize(Policy = Policies.DefaultAuthorization)] public class PluginsController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; private readonly IPluginManager _pluginManager; private readonly IConfigurationManager _config; private readonly JsonSerializerOptions _serializerOptions; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public PluginsController( IInstallationManager installationManager, IPluginManager pluginManager, IConfigurationManager config) { _installationManager = installationManager; _pluginManager = pluginManager; _serializerOptions = JsonDefaults.GetCamelCaseOptions(); _config = config; } /// /// Get plugin security info. /// /// Plugin security info returned. /// Plugin security info. [Obsolete("This endpoint should not be used.")] [HttpGet("SecurityInfo")] [ProducesResponseType(StatusCodes.Status200OK)] public static ActionResult GetPluginSecurityInfo() { return new PluginSecurityInfo { IsMbSupporter = true, SupporterKey = "IAmTotallyLegit" }; } /// /// Gets registration status for a feature. /// /// Feature name. /// Registration status returned. /// Mb registration record. [Obsolete("This endpoint should not be used.")] [HttpPost("RegistrationRecords/{name}")] [ProducesResponseType(StatusCodes.Status200OK)] public static ActionResult GetRegistrationStatus([FromRoute, Required] string name) { return new MBRegistrationRecord { IsRegistered = true, RegChecked = true, TrialVersion = false, IsValid = true, RegError = false }; } /// /// Gets registration status for a feature. /// /// Feature name. /// Not implemented. /// Not Implemented. /// This endpoint is not implemented. [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(); } /// /// Gets a list of currently installed plugins. /// /// Installed plugins returned. /// List of currently installed plugins. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetPlugins() { return Ok(_pluginManager.Plugins .OrderBy(p => p.Name) .Select(p => p.GetPluginInfo())); } /// /// Enables a disabled plugin. /// /// Plugin id. /// Plugin version. /// Plugin enabled. /// Plugin not found. /// An on success, or a if the file could not be found. [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) { if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin)) { return NotFound(); } _pluginManager.EnablePlugin(plugin!); return NoContent(); } /// /// Disable a plugin. /// /// Plugin id. /// Plugin version. /// Plugin disabled. /// Plugin not found. /// An on success, or a if the file could not be found. [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) { if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin)) { return NotFound(); } _pluginManager.DisablePlugin(plugin!); return NoContent(); } /// /// Uninstalls a plugin by version. /// /// Plugin id. /// Plugin version. /// Plugin uninstalled. /// Plugin not found. /// An on success, or a if the file could not be found. [HttpDelete("{pluginId}/{version}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) { if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin)) { return NotFound(); } _installationManager.UninstallPlugin(plugin!); return NoContent(); } /// /// Uninstalls a plugin. /// /// Plugin id. /// Plugin uninstalled. /// Plugin not found. /// An on success, or a if the file could not be found. [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(); } _installationManager.UninstallPlugin(plugin!); return NoContent(); } /// /// Gets plugin configuration. /// /// Plugin id. /// Plugin configuration returned. /// Plugin not found or plugin configuration not found. /// Plugin configuration. [HttpGet("{pluginId}/Configuration")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesFile(MediaTypeNames.Application.Json)] public ActionResult GetPluginConfiguration([FromRoute, Required] Guid pluginId) { if (_pluginManager.TryGetPlugin(pluginId, null, out var plugin) && plugin!.Instance is IHasPluginConfiguration configPlugin) { return configPlugin.Configuration; } return NotFound(); } /// /// Updates plugin configuration. /// /// /// Accepts plugin configuration as JSON body. /// /// Plugin id. /// Plugin configuration updated. /// Plugin not found or plugin does not have configuration. /// /// A that represents the asynchronous operation to update plugin configuration. /// The task result contains an indicating success, or /// when plugin not found or plugin doesn't have configuration. /// [HttpPost("{pluginId}/Configuration")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) { if (!_pluginManager.TryGetPlugin(pluginId, null, out var plugin) || plugin?.Instance is not IHasPluginConfiguration configPlugin) { return NotFound(); } var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) .ConfigureAwait(false); if (configuration != null) { configPlugin.UpdateConfiguration(configuration); } return NoContent(); } /// /// Gets a plugin's image. /// /// Plugin id. /// Plugin version. /// Plugin image returned. /// Plugin's image. [HttpGet("{pluginId}/{version}/Image")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] [AllowAnonymous] public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) { if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin)) { return NotFound(); } var imgPath = Path.Combine(plugin!.Path, plugin!.Manifest.ImageUrl ?? string.Empty); if (((ServerConfiguration)_config.CommonConfiguration).DisablePluginImages || plugin!.Manifest.ImageUrl == null || !System.IO.File.Exists(imgPath)) { // Use a blank image. var type = GetType(); var stream = type.Assembly.GetManifestResourceStream(type.Namespace + ".Plugins.blank.png"); return File(stream, "image/png"); } imgPath = Path.Combine(plugin.Path, plugin.Manifest.ImageUrl); return PhysicalFile(imgPath, MimeTypes.GetMimeType(imgPath)); } /// /// Gets a plugin's manifest. /// /// Plugin id. /// Plugin manifest returned. /// Plugin not found. /// /// A that represents the asynchronous operation to get the plugin's manifest. /// The task result contains an indicating success, or /// when plugin not found. /// [HttpPost("{pluginId}/Manifest")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesFile(MediaTypeNames.Application.Json)] public ActionResult GetPluginManifest([FromRoute, Required] Guid pluginId) { if (_pluginManager.TryGetPlugin(pluginId, null, out var plugin)) { return Ok(plugin!.Manifest); } return NotFound(); } /// /// Updates plugin security info. /// /// Plugin security info. /// Plugin security info updated. /// An . [Obsolete("This endpoint should not be used.")] [HttpPost("SecurityInfo")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdatePluginSecurityInfo([FromBody, Required] PluginSecurityInfo pluginSecurityInfo) { return NoContent(); } } }