Initial upload

This commit is contained in:
Greenback 2020-12-06 23:48:54 +00:00
parent f2c2beca0f
commit 7986465cf7
34 changed files with 1726 additions and 755 deletions

View File

@ -34,7 +34,6 @@ using Emby.Server.Implementations.LiveTv;
using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Net; using Emby.Server.Implementations.Net;
using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.Plugins;
using Emby.Server.Implementations.QuickConnect; using Emby.Server.Implementations.QuickConnect;
using Emby.Server.Implementations.ScheduledTasks; using Emby.Server.Implementations.ScheduledTasks;
using Emby.Server.Implementations.Security; using Emby.Server.Implementations.Security;
@ -119,7 +118,9 @@ namespace Emby.Server.Implementations
private readonly IXmlSerializer _xmlSerializer; private readonly IXmlSerializer _xmlSerializer;
private readonly IJsonSerializer _jsonSerializer; private readonly IJsonSerializer _jsonSerializer;
private readonly IStartupOptions _startupOptions; private readonly IStartupOptions _startupOptions;
private readonly IPluginManager _pluginManager;
private List<Type> _creatingInstances;
private IMediaEncoder _mediaEncoder; private IMediaEncoder _mediaEncoder;
private ISessionManager _sessionManager; private ISessionManager _sessionManager;
private string[] _urlPrefixes; private string[] _urlPrefixes;
@ -181,16 +182,6 @@ namespace Emby.Server.Implementations
protected IServiceCollection ServiceCollection { get; } 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> /// <summary>
/// Gets the logger factory. /// Gets the logger factory.
/// </summary> /// </summary>
@ -294,6 +285,14 @@ namespace Emby.Server.Implementations
ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version;
ApplicationVersionString = ApplicationVersion.ToString(3); ApplicationVersionString = ApplicationVersion.ToString(3);
ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
_pluginManager = new PluginManager(
LoggerFactory,
this,
ServerConfigurationManager.Configuration,
ApplicationPaths.PluginsPath,
ApplicationPaths.CachePath,
ApplicationVersion);
} }
/// <summary> /// <summary>
@ -393,8 +392,26 @@ namespace Emby.Server.Implementations
/// <returns>System.Object.</returns> /// <returns>System.Object.</returns>
protected object CreateInstanceSafe(Type type) protected object CreateInstanceSafe(Type type)
{ {
if (_creatingInstances == null)
{
_creatingInstances = new List<Type>();
}
if (_creatingInstances.IndexOf(type) != -1)
{
Logger.LogError("DI Loop detected.");
Logger.LogError("Attempted creation of {Type}", type.FullName);
foreach (var entry in _creatingInstances)
{
Logger.LogError("Called from: {stack}", entry.FullName);
}
throw new ExternalException("DI Loop detected.");
}
try try
{ {
_creatingInstances.Add(type);
Logger.LogDebug("Creating instance of {Type}", type); Logger.LogDebug("Creating instance of {Type}", type);
return ActivatorUtilities.CreateInstance(ServiceProvider, type); return ActivatorUtilities.CreateInstance(ServiceProvider, type);
} }
@ -403,6 +420,10 @@ namespace Emby.Server.Implementations
Logger.LogError(ex, "Error creating {Type}", type); Logger.LogError(ex, "Error creating {Type}", type);
return null; return null;
} }
finally
{
_creatingInstances.Remove(type);
}
} }
/// <summary> /// <summary>
@ -412,11 +433,7 @@ namespace Emby.Server.Implementations
/// <returns>``0.</returns> /// <returns>``0.</returns>
public T Resolve<T>() => ServiceProvider.GetService<T>(); public T Resolve<T>() => ServiceProvider.GetService<T>();
/// <summary> /// <inheritdoc/>
/// Gets the export types.
/// </summary>
/// <typeparam name="T">The type.</typeparam>
/// <returns>IEnumerable{Type}.</returns>
public IEnumerable<Type> GetExportTypes<T>() public IEnumerable<Type> GetExportTypes<T>()
{ {
var currentType = typeof(T); var currentType = typeof(T);
@ -445,6 +462,27 @@ namespace Emby.Server.Implementations
return parts; 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> /// <summary>
/// Runs the startup tasks. /// Runs the startup tasks.
/// </summary> /// </summary>
@ -509,7 +547,7 @@ namespace Emby.Server.Implementations
RegisterServices(); RegisterServices();
RegisterPluginServices(); _pluginManager.RegisterServices(ServiceCollection);
} }
/// <summary> /// <summary>
@ -523,7 +561,7 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton(ConfigurationManager); ServiceCollection.AddSingleton(ConfigurationManager);
ServiceCollection.AddSingleton<IApplicationHost>(this); ServiceCollection.AddSingleton<IApplicationHost>(this);
ServiceCollection.AddSingleton<IPluginManager>(_pluginManager);
ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>(); ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>();
@ -768,34 +806,7 @@ namespace Emby.Server.Implementations
} }
ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); ConfigurationManager.AddParts(GetExports<IConfigurationFactory>());
_plugins = GetExports<IPlugin>() _pluginManager.CreatePlugins();
.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);
}
}
_urlPrefixes = GetUrlPrefixes().ToArray(); _urlPrefixes = GetUrlPrefixes().ToArray();
@ -834,22 +845,6 @@ namespace Emby.Server.Implementations
_allConcreteTypes = GetTypes(GetComposablePartAssemblies()).ToArray(); _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) private IEnumerable<Type> GetTypes(IEnumerable<Assembly> assemblies)
{ {
foreach (var ass in assemblies) foreach (var ass in assemblies)
@ -862,11 +857,13 @@ namespace Emby.Server.Implementations
catch (FileNotFoundException ex) catch (FileNotFoundException ex)
{ {
Logger.LogError(ex, "Error getting exported types from {Assembly}", ass.FullName); Logger.LogError(ex, "Error getting exported types from {Assembly}", ass.FullName);
_pluginManager.FailPlugin(ass);
continue; continue;
} }
catch (TypeLoadException ex) catch (TypeLoadException ex)
{ {
Logger.LogError(ex, "Error loading types from {Assembly}.", ass.FullName); Logger.LogError(ex, "Error loading types from {Assembly}.", ass.FullName);
_pluginManager.FailPlugin(ass);
continue; continue;
} }
@ -1005,129 +1002,15 @@ namespace Emby.Server.Implementations
protected abstract void RestartInternal(); 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 manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile);
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> /// <summary>
/// Gets the composable part assemblies. /// Gets the composable part assemblies.
/// </summary> /// </summary>
/// <returns>IEnumerable{Assembly}.</returns> /// <returns>IEnumerable{Assembly}.</returns>
protected IEnumerable<Assembly> GetComposablePartAssemblies() protected IEnumerable<Assembly> GetComposablePartAssemblies()
{ {
if (Directory.Exists(ApplicationPaths.PluginsPath)) foreach (var p in _pluginManager.LoadAssemblies())
{ {
_pluginsManifests = GetLocalPlugins(ApplicationPaths.PluginsPath).ToList(); yield return p;
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;
}
}
} }
// Include composable parts in the Model assembly // Include composable parts in the Model assembly
@ -1369,17 +1252,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() public IEnumerable<Assembly> GetApiPluginAssemblies()
{ {
var assemblies = _allConcreteTypes var assemblies = _allConcreteTypes

View File

@ -73,6 +73,12 @@
<EmbeddedResource Include="Localization\countries.json" /> <EmbeddedResource Include="Localization\countries.json" />
<EmbeddedResource Include="Localization\Core\*.json" /> <EmbeddedResource Include="Localization\Core\*.json" />
<EmbeddedResource Include="Localization\Ratings\*.csv" /> <EmbeddedResource Include="Localization\Ratings\*.csv" />
<EmbeddedResource Include="Plugins\blank.png" />
<EmbeddedResource Include="Plugins\Superceded.png" />
<EmbeddedResource Include="Plugins\Disabled.png" />
<EmbeddedResource Include="Plugins\NotSupported.png" />
<EmbeddedResource Include="Plugins\Malfunction.png" />
<EmbeddedResource Include="Plugins\RestartRequired.png" />
<EmbeddedResource Include="Plugins\Active.png" />
</ItemGroup> </ItemGroup>
</Project> </Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,674 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
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
{
/// <summary>
/// Defines the <see cref="PluginManager" />.
/// </summary>
public class PluginManager : IPluginManager
{
private const int OffsetFromTopRightCorner = 38;
private readonly string _pluginsPath;
private readonly Version _appVersion;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ILogger<PluginManager> _logger;
private readonly IApplicationHost _appHost;
private readonly string _imagesPath;
private readonly ServerConfiguration _config;
private readonly IList<LocalPlugin> _plugins;
private readonly Version _nextVersion;
private readonly Version _minimumVersion;
/// <summary>
/// Initializes a new instance of the <see cref="PluginManager"/> class.
/// </summary>
/// <param name="loggerfactory">The <see cref="ILoggerFactory"/>.</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="imagesPath">The image cache path.</param>
/// <param name="appVersion">The application version.</param>
public PluginManager(
ILoggerFactory loggerfactory,
IApplicationHost appHost,
ServerConfiguration config,
string pluginsPath,
string imagesPath,
Version appVersion)
{
_logger = loggerfactory.CreateLogger<PluginManager>();
_pluginsPath = pluginsPath;
_appVersion = appVersion ?? throw new ArgumentNullException(nameof(appVersion));
_jsonOptions = JsonDefaults.GetOptions();
_jsonOptions.PropertyNameCaseInsensitive = true;
_jsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
_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<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()
{
foreach (var plugin in _plugins)
{
foreach (var file in plugin.DllFiles)
{
try
{
plugin.Assembly = Assembly.LoadFrom(file);
}
catch (FileLoadException ex)
{
_logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file);
ChangePluginState(plugin, PluginStatus.Malfunction);
continue;
}
_logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugin.Assembly.FullName, file);
yield return plugin.Assembly;
}
}
}
/// <summary>
/// Creates all the plugin instances.
/// </summary>
public void CreatePlugins()
{
var createdPlugins = _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 = GetPluginByType(pluginServiceRegistrator.Assembly.GetType());
if (plugin == null)
{
throw new NullReferenceException();
}
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.Malfunction))
{
_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 (plugin == null || _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));
}
plugin.Instance?.OnUninstalling();
if (DeletePlugin(plugin))
{
return true;
}
// Unable to delete, so disable.
return ChangePluginState(plugin, PluginStatus.Disabled);
}
/// <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>
/// <param name="plugin">A <see cref="LocalPlugin"/> if found, otherwise null.</param>
/// <returns>Boolean value signifying the success of the search.</returns>
public bool TryGetPlugin(Guid id, Version? version, out LocalPlugin? plugin)
{
if (version == null)
{
// If no version is given, return the largest version number. (This is for backwards compatibility).
plugin = _plugins.Where(p => p.Id.Equals(id)).OrderByDescending(p => p.Version).FirstOrDefault();
}
else
{
plugin = _plugins.FirstOrDefault(p => p.Id.Equals(id) && p.Version.Equals(version));
}
return plugin != null;
}
/// <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))
{
UpdateSuccessors(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))
{
UpdateSuccessors(plugin);
}
}
/// <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 UpdateSuccessors(LocalPlugin plugin)
{
// This value is memory only - so that the web will show restart required.
plugin.Manifest.Status = PluginStatus.RestartRequired;
// 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.Name.Equals(plugin.Name, StringComparison.OrdinalIgnoreCase)
&& p.IsEnabledAndSupported
&& p.Version != plugin.Version);
if (predecessor == null)
{
return;
}
if (!ChangePluginState(predecessor, PluginStatus.Superceded))
{
_logger.LogError("Unable to disable version {Version} of {Name}", predecessor.Version, predecessor.Name);
}
}
/// <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.Where(p => assembly.Equals(p.Assembly)).FirstOrDefault();
if (plugin == null)
{
// A plugin's assembly didn't cause this issue, so ignore it.
return;
}
ChangePluginState(plugin, PluginStatus.Malfunction);
}
/// <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;
SaveManifest(plugin.Manifest, plugin.Path);
try
{
var data = JsonSerializer.Serialize(plugin.Manifest, _jsonOptions);
File.WriteAllText(Path.Combine(plugin.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 disable plugin {Path}", plugin.Path);
return false;
}
}
/// <summary>
/// Finds the plugin record using the type.
/// </summary>
/// <param name="type">The <see cref="Type"/> being sought.</param>
/// <returns>The matching record, or null if not found.</returns>
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));
}
/// <summary>
/// Creates the instance safe.
/// </summary>
/// <param name="type">The type.</param>
/// <returns>System.Object.</returns>
private object? CreatePluginInstance(Type type)
{
// Find the record for this plugin.
var plugin = GetPluginByType(type);
if (plugin != null)
{
CheckIfStillSuperceded(plugin);
if (plugin.IsEnabledAndSupported == true)
{
_logger.LogInformation("Skipping disabled plugin {Version} of {Name} ", plugin.Version, plugin.Name);
return null;
}
}
try
{
_logger.LogDebug("Creating instance of {Type}", type);
var instance = 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();
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;
}
manifest.Status = PluginStatus.Active;
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.Malfunction))
{
_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;
}
/// <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
{
_logger.LogDebug("Deleting {Path}", plugin.Path);
Directory.Delete(plugin.Path, 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}", plugin.Path);
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<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.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.
// NOTE: This Plugin is marked as valid for two upgrades, at which point, it can be assumed the
// code base will have changed sufficiently to make it invalid.
manifest = new PluginManifest
{
Status = PluginStatus.RestartRequired,
Name = metafile,
AutoUpdate = false,
Guid = metafile.GetMD5(),
TargetAbi = _appVersion.ToString(),
MaxAbi = _nextVersion.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;
}
}
/// <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);
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);
}
versions.RemoveAt(x);
}
if (!cleaned)
{
if (manifest == null)
{
_logger.LogWarning("Unable to disable plugin {Path}", entry.Path);
continue;
}
// Update the manifest so its not loaded next time.
manifest.Status = PluginStatus.Disabled;
SaveManifest(manifest, entry.Path);
}
}
// Only want plugin folders which have files.
return versions.Where(p => p.DllFiles.Count != 0);
}
}
}

View File

@ -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; }
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -8,10 +8,10 @@ using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Common.Updates; using MediaBrowser.Common.Updates;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Net; using MediaBrowser.Model.Net;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MediaBrowser.Model.Globalization;
namespace Emby.Server.Implementations.ScheduledTasks namespace Emby.Server.Implementations.ScheduledTasks
{ {

View File

@ -1,4 +1,4 @@
#pragma warning disable CS1591 #nullable enable
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@ -12,7 +12,6 @@ using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Events; using Jellyfin.Data.Events;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Json; using MediaBrowser.Common.Json;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
@ -41,17 +40,15 @@ namespace Emby.Server.Implementations.Updates
private readonly IEventManager _eventManager; private readonly IEventManager _eventManager;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerConfigurationManager _config; private readonly IServerConfigurationManager _config;
private readonly IFileSystem _fileSystem;
private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly IPluginManager _pluginManager;
/// <summary> /// <summary>
/// Gets the application host. /// Gets the application host.
/// </summary> /// </summary>
/// <value>The application host.</value> /// <value>The application host.</value>
private readonly IServerApplicationHost _applicationHost; private readonly IServerApplicationHost _applicationHost;
private readonly IZipClient _zipClient; private readonly IZipClient _zipClient;
private readonly object _currentInstallationsLock = new object(); private readonly object _currentInstallationsLock = new object();
/// <summary> /// <summary>
@ -64,6 +61,17 @@ namespace Emby.Server.Implementations.Updates
/// </summary> /// </summary>
private readonly ConcurrentBag<InstallationInfo> _completedInstallationsInternal; 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( public InstallationManager(
ILogger<InstallationManager> logger, ILogger<InstallationManager> logger,
IServerApplicationHost appHost, IServerApplicationHost appHost,
@ -71,8 +79,8 @@ namespace Emby.Server.Implementations.Updates
IEventManager eventManager, IEventManager eventManager,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IServerConfigurationManager config, IServerConfigurationManager config,
IFileSystem fileSystem, IZipClient zipClient,
IZipClient zipClient) IPluginManager pluginManager)
{ {
_currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
_completedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
@ -83,16 +91,17 @@ namespace Emby.Server.Implementations.Updates
_eventManager = eventManager; _eventManager = eventManager;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_config = config; _config = config;
_fileSystem = fileSystem;
_zipClient = zipClient; _zipClient = zipClient;
_jsonSerializerOptions = JsonDefaults.GetOptions(); _jsonSerializerOptions = JsonDefaults.GetOptions();
_jsonSerializerOptions.PropertyNameCaseInsensitive = true;
_pluginManager = pluginManager;
} }
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal; public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
/// <inheritdoc /> /// <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 try
{ {
@ -103,13 +112,39 @@ namespace Emby.Server.Implementations.Updates
return Array.Empty<PackageInfo>(); 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. // Store the repository and repository url with each version, as they may be spread apart.
foreach (var entry in packages) foreach (var entry in packages)
{ {
foreach (var ver in entry.versions) for (int a = entry.Versions.Count - 1; a >= 0; a--)
{ {
ver.repositoryName = manifestName; var ver = entry.Versions[a];
ver.repositoryUrl = manifest; ver.RepositoryName = manifestName;
ver.RepositoryUrl = manifest;
if (!filterIncompatible)
{
continue;
}
if (!Version.TryParse(ver.TargetAbi, out var targetAbi))
{
targetAbi = minimumVersion;
}
if (!Version.TryParse(ver.MaxAbi, out var maxAbi))
{
maxAbi = _applicationHost.ApplicationVersion;
}
// Only show plugins that fall between targetAbi and maxAbi
if (_applicationHost.ApplicationVersion >= targetAbi && _applicationHost.ApplicationVersion <= maxAbi)
{
continue;
}
// Not compatible with this version so remove it.
entry.Versions.Remove(ver);
} }
} }
@ -132,69 +167,61 @@ 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 /> /// <inheritdoc />
public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
{ {
var result = new List<PackageInfo>(); var result = new List<PackageInfo>();
foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories) 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. // Where repositories have the same content, the details from the first is taken.
foreach (var package in await GetPackages(repository.Name, repository.Url, cancellationToken).ConfigureAwait(true)) 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.Guid, out var packageGuid))
{ {
// Package doesn't have a valid GUID, skip. // Package doesn't have a valid GUID, skip.
continue; continue;
} }
var existing = FilterPackages(result, package.name, packageGuid).FirstOrDefault(); 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];
// Update the manifests, if anything changes.
if (_pluginManager.TryGetPlugin(packageGuid, version.VersionNumber, out LocalPlugin? plugin))
{
bool noChange = string.Equals(plugin!.Manifest.MaxAbi, version.MaxAbi, StringComparison.Ordinal)
|| string.Equals(plugin.Manifest.TargetAbi, version.TargetAbi, StringComparison.Ordinal);
if (!noChange)
{
plugin.Manifest.MaxAbi = version.MaxAbi ?? string.Empty;
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(version.TargetAbi, out var targetAbi) && _applicationHost.ApplicationVersion < targetAbi)
|| (Version.TryParse(version.MaxAbi, out var maxAbi) && _applicationHost.ApplicationVersion > maxAbi))
{
package.Versions.RemoveAt(i);
}
}
// Don't add a package that doesn't have any compatible versions.
if (package.Versions.Count == 0)
{
continue;
}
if (existing != null) if (existing != null)
{ {
// Assumption is both lists are ordered, so slot these into the correct place. // Assumption is both lists are ordered, so slot these into the correct place.
MergeSort(existing.versions, package.versions); MergeSortedList(existing.Versions, package.Versions);
} }
else else
{ {
@ -210,23 +237,23 @@ namespace Emby.Server.Implementations.Updates
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<PackageInfo> FilterPackages( public IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages, IEnumerable<PackageInfo> availablePackages,
string name = null, string? name = null,
Guid guid = default, Guid? guid = default,
Version specificVersion = null) Version? specificVersion = null)
{ {
if (name != 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 (guid != Guid.Empty)
{ {
availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid); availablePackages = availablePackages.Where(x => Guid.Parse(x.Guid) == guid);
} }
if (specificVersion != null) 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; return availablePackages;
@ -235,10 +262,10 @@ namespace Emby.Server.Implementations.Updates
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<InstallationInfo> GetCompatibleVersions( public IEnumerable<InstallationInfo> GetCompatibleVersions(
IEnumerable<PackageInfo> availablePackages, IEnumerable<PackageInfo> availablePackages,
string name = null, string? name = null,
Guid guid = default, Guid? guid = default,
Version minVersion = null, Version? minVersion = null,
Version specificVersion = null) Version? specificVersion = null)
{ {
var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault(); var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
@ -249,8 +276,9 @@ namespace Emby.Server.Implementations.Updates
} }
var appVer = _applicationHost.ApplicationVersion; var appVer = _applicationHost.ApplicationVersion;
var availableVersions = package.versions var availableVersions = package.Versions
.Where(x => Version.Parse(x.targetAbi) <= appVer); .Where(x => (string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer)
&& (string.IsNullOrEmpty(x.MaxAbi) || Version.Parse(x.MaxAbi) >= appVer));
if (specificVersion != null) if (specificVersion != null)
{ {
@ -265,12 +293,12 @@ namespace Emby.Server.Implementations.Updates
{ {
yield return new InstallationInfo yield return new InstallationInfo
{ {
Changelog = v.changelog, Changelog = v.Changelog,
Guid = new Guid(package.guid), Guid = new Guid(package.Guid),
Name = package.name, Name = package.Name,
Version = v.VersionNumber, Version = v.VersionNumber,
SourceUrl = v.sourceUrl, SourceUrl = v.SourceUrl,
Checksum = v.checksum Checksum = v.Checksum
}; };
} }
} }
@ -282,20 +310,6 @@ namespace Emby.Server.Implementations.Updates
return GetAvailablePluginUpdates(catalog); 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 /> /// <inheritdoc />
public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken) public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken)
{ {
@ -373,24 +387,140 @@ namespace Emby.Server.Implementations.Updates
} }
/// <summary> /// <summary>
/// Installs the package internal. /// Uninstalls a plugin.
/// </summary> /// </summary>
/// <param name="package">The package.</param> /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param>
/// <param name="cancellationToken">The cancellation token.</param> public void UninstallPlugin(LocalPlugin plugin)
/// <returns><see cref="Task" />.</returns>
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
{ {
// Set last update time if we were installed before if (plugin == null)
IPlugin plugin = _applicationHost.Plugins.FirstOrDefault(p => p.Id == package.Guid) {
?? _applicationHost.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase)); return;
}
// Do the install if (plugin.Instance?.CanUninstall == false)
await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false); {
_logger.LogWarning("Attempt to delete non removable plugin {0}, ignoring request", plugin.Name);
return;
}
// Do plugin-specific processing plugin.Instance?.OnUninstalling();
_logger.LogInformation(plugin == null ? "New plugin installed: {0} {1}" : "Plugin updated: {0} {1}", package.Name, package.Version);
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.Guid == 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.Guid != version.Guid))
{
yield return version;
}
}
} }
private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken) private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken)
@ -434,7 +564,9 @@ namespace Emby.Server.Implementations.Updates
{ {
Directory.Delete(targetDir, true); Directory.Delete(targetDir, true);
} }
#pragma warning disable CA1031 // Do not catch general exception types
catch catch
#pragma warning restore CA1031 // Do not catch general exception types
{ {
// Ignore any exceptions. // Ignore any exceptions.
} }
@ -442,119 +574,27 @@ namespace Emby.Server.Implementations.Updates
stream.Position = 0; stream.Position = 0;
_zipClient.ExtractAllFromZip(stream, targetDir, true); _zipClient.ExtractAllFromZip(stream, targetDir, true);
_pluginManager.ImportPluginFrom(targetDir);
#pragma warning restore CA5351
} }
/// <summary> private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
/// Uninstalls a plugin.
/// </summary>
/// <param name="plugin">The plugin.</param>
public void UninstallPlugin(IPlugin plugin)
{ {
if (!plugin.CanUninstall) // Set last update time if we were installed before
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Guid) && p.Version.Equals(package.Version))
?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase) && p.Version.Equals(package.Version));
if (plugin != null)
{ {
_logger.LogWarning("Attempt to delete non removable plugin {0}, ignoring request", plugin.Name); plugin.Manifest.Timestamp = DateTime.UtcNow;
return; _pluginManager.SaveManifest(plugin.Manifest, plugin.Path);
} }
plugin.OnUninstalling(); // Do the install
await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false);
// Remove it the quick way for now // Do plugin-specific processing
_applicationHost.RemovePlugin(plugin); _logger.LogInformation(plugin == null ? "New plugin installed: {0} {1}" : "Plugin updated: {0} {1}", package.Name, package.Version);
var path = plugin.AssemblyFilePath; return plugin != null;
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();
}
/// <inheritdoc/>
public bool CancelInstallation(Guid id)
{
lock (_currentInstallationsLock)
{
var install = _currentInstallations.Find(x => x.info.Guid == 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 tuple in _currentInstallations)
{
tuple.token.Dispose();
}
_currentInstallations.Clear();
}
}
} }
} }
} }

View File

@ -29,18 +29,22 @@ namespace Jellyfin.Api.Controllers
{ {
private readonly ILogger<DashboardController> _logger; private readonly ILogger<DashboardController> _logger;
private readonly IServerApplicationHost _appHost; private readonly IServerApplicationHost _appHost;
private readonly IPluginManager _pluginManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="DashboardController"/> class. /// Initializes a new instance of the <see cref="DashboardController"/> class.
/// </summary> /// </summary>
/// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
/// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
/// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param>
public DashboardController( public DashboardController(
ILogger<DashboardController> logger, ILogger<DashboardController> logger,
IServerApplicationHost appHost) IServerApplicationHost appHost,
IPluginManager pluginManager)
{ {
_logger = logger; _logger = logger;
_appHost = appHost; _appHost = appHost;
_pluginManager = pluginManager;
} }
/// <summary> /// <summary>
@ -83,7 +87,7 @@ namespace Jellyfin.Api.Controllers
.Where(i => i != null) .Where(i => i != null)
.ToList(); .ToList();
configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); configPages.AddRange(_pluginManager.Plugins.SelectMany(GetConfigPages));
if (pageType.HasValue) if (pageType.HasValue)
{ {
@ -155,24 +159,24 @@ namespace Jellyfin.Api.Controllers
return NotFound(); 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 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() private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
{ {
return _appHost.Plugins.SelectMany(GetPluginPages); return _pluginManager.Plugins.SelectMany(GetPluginPages);
} }
} }
} }

View File

@ -2,8 +2,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Net.Mime;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using MediaBrowser.Common.Json;
using MediaBrowser.Common.Updates; using MediaBrowser.Common.Updates;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates; using MediaBrowser.Model.Updates;
@ -43,6 +46,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="PackageInfo"/> containing package information.</returns> /// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
[HttpGet("Packages/{name}")] [HttpGet("Packages/{name}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[Produces(JsonDefaults.CamelCaseMediaType)]
public async Task<ActionResult<PackageInfo>> GetPackageInfo( public async Task<ActionResult<PackageInfo>> GetPackageInfo(
[FromRoute, Required] string name, [FromRoute, Required] string name,
[FromQuery] Guid? assemblyGuid) [FromQuery] Guid? assemblyGuid)
@ -69,6 +73,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
[HttpGet("Packages")] [HttpGet("Packages")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[Produces(JsonDefaults.CamelCaseMediaType)]
public async Task<IEnumerable<PackageInfo>> GetPackages() public async Task<IEnumerable<PackageInfo>> GetPackages()
{ {
IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
@ -99,7 +104,7 @@ namespace Jellyfin.Api.Controllers
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl)) 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(); .ToList();
} }
@ -143,6 +148,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns> /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns>
[HttpGet("Repositories")] [HttpGet("Repositories")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[Produces(JsonDefaults.CamelCaseMediaType)]
public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories() public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories()
{ {
return _serverConfigurationManager.Configuration.PluginRepositories; return _serverConfigurationManager.Configuration.PluginRepositories;

View File

@ -1,15 +1,22 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Net.Mime;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.PluginDtos; using Jellyfin.Api.Models.PluginDtos;
using MediaBrowser.Common; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Json; using MediaBrowser.Common.Json;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Updates; using MediaBrowser.Common.Updates;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -23,112 +30,26 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)] [Authorize(Policy = Policies.DefaultAuthorization)]
public class PluginsController : BaseJellyfinApiController public class PluginsController : BaseJellyfinApiController
{ {
private readonly IApplicationHost _appHost;
private readonly IInstallationManager _installationManager; private readonly IInstallationManager _installationManager;
private readonly IPluginManager _pluginManager;
private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.GetOptions(); private readonly IConfigurationManager _config;
private readonly JsonSerializerOptions _serializerOptions;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PluginsController"/> class. /// Initializes a new instance of the <see cref="PluginsController"/> class.
/// </summary> /// </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="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( public PluginsController(
IApplicationHost appHost, IInstallationManager installationManager,
IInstallationManager installationManager) IPluginManager pluginManager,
IConfigurationManager config)
{ {
_appHost = appHost;
_installationManager = installationManager; _installationManager = installationManager;
} _pluginManager = pluginManager;
_serializerOptions = JsonDefaults.GetOptions();
/// <summary> _config = config;
/// Gets a list of currently installed plugins.
/// </summary>
/// <response code="200">Installed plugins returned.</response>
/// <returns>List of currently installed plugins.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PluginInfo>> GetPlugins()
{
return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo()));
}
/// <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 file could not be found.</returns>
[HttpDelete("{pluginId}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
{
var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
if (plugin == null)
{
return NotFound();
}
_installationManager.UninstallPlugin(plugin);
return NoContent();
}
/// <summary>
/// Gets plugin configuration.
/// </summary>
/// <param name="pluginId">Plugin id.</param>
/// <response code="200">Plugin configuration returned.</response>
/// <response code="404">Plugin not found or plugin configuration not found.</response>
/// <returns>Plugin configuration.</returns>
[HttpGet("{pluginId}/Configuration")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId)
{
if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
{
return NotFound();
}
return plugin.Configuration;
}
/// <summary>
/// Updates plugin configuration.
/// </summary>
/// <remarks>
/// Accepts plugin configuration as JSON body.
/// </remarks>
/// <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>
[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))
{
return NotFound();
}
var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType, _serializerOptions)
.ConfigureAwait(false);
if (configuration != null)
{
plugin.UpdateConfiguration(configuration);
}
return NoContent();
} }
/// <summary> /// <summary>
@ -139,7 +60,7 @@ namespace Jellyfin.Api.Controllers
[Obsolete("This endpoint should not be used.")] [Obsolete("This endpoint should not be used.")]
[HttpGet("SecurityInfo")] [HttpGet("SecurityInfo")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo() public static ActionResult<PluginSecurityInfo> GetPluginSecurityInfo()
{ {
return new PluginSecurityInfo return new PluginSecurityInfo
{ {
@ -148,21 +69,6 @@ namespace Jellyfin.Api.Controllers
}; };
} }
/// <summary>
/// Updates plugin security info.
/// </summary>
/// <param name="pluginSecurityInfo">Plugin security info.</param>
/// <response code="204">Plugin security info updated.</response>
/// <returns>An <see cref="NoContentResult"/>.</returns>
[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();
}
/// <summary> /// <summary>
/// Gets registration status for a feature. /// Gets registration status for a feature.
/// </summary> /// </summary>
@ -172,7 +78,7 @@ namespace Jellyfin.Api.Controllers
[Obsolete("This endpoint should not be used.")] [Obsolete("This endpoint should not be used.")]
[HttpPost("RegistrationRecords/{name}")] [HttpPost("RegistrationRecords/{name}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute, Required] string name) public static ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute, Required] string name)
{ {
return new MBRegistrationRecord return new MBRegistrationRecord
{ {
@ -194,11 +100,257 @@ namespace Jellyfin.Api.Controllers
[Obsolete("Paid plugins are not supported")] [Obsolete("Paid plugins are not supported")]
[HttpGet("Registrations/{name}")] [HttpGet("Registrations/{name}")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)] [ProducesResponseType(StatusCodes.Status501NotImplemented)]
public ActionResult GetRegistration([FromRoute, Required] string name) public static ActionResult GetRegistration([FromRoute, Required] string name)
{ {
// TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, // 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. // delete all these registration endpoints. They are only kept for compatibility.
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <summary>
/// Gets a list of currently installed plugins.
/// </summary>
/// <response code="200">Installed plugins returned.</response>
/// <returns>List of currently installed plugins.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile(MediaTypeNames.Application.Json)]
public ActionResult<IEnumerable<PluginInfo>> GetPlugins()
{
return Ok(_pluginManager.Plugins
.OrderBy(p => p.Name)
.Select(p => p.GetPluginInfo()));
}
/// <summary>
/// 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 file could not be found.</returns>
[HttpPost("{pluginId}/Enable")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
{
if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
{
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 file could not be found.</returns>
[HttpPost("{pluginId}/Disable")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
{
if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
{
return NotFound();
}
_pluginManager.DisablePlugin(plugin!);
return NoContent();
}
/// <summary>
/// Uninstalls a plugin.
/// </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}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId, Version version)
{
if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
{
return NotFound();
}
_installationManager.UninstallPlugin(plugin!);
return NoContent();
}
/// <summary>
/// Gets plugin configuration.
/// </summary>
/// <param name="pluginId">Plugin id.</param>
/// <param name="version">Plugin version.</param>
/// <response code="200">Plugin configuration returned.</response>
/// <response code="404">Plugin not found or plugin configuration not found.</response>
/// <returns>Plugin configuration.</returns>
[HttpGet("{pluginId}/Configuration")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesFile(MediaTypeNames.Application.Json)]
public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
{
if (_pluginManager.TryGetPlugin(pluginId, version, out var plugin)
&& plugin!.Instance is IHasPluginConfiguration configPlugin)
{
return configPlugin.Configuration;
}
return NotFound();
}
/// <summary>
/// Updates plugin configuration.
/// </summary>
/// <remarks>
/// Accepts plugin configuration as JSON body.
/// </remarks>
/// <param name="pluginId">Plugin id.</param>
/// <param name="version">Plugin version.</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>
[HttpPost("{pluginId}/Configuration")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
{
if (!_pluginManager.TryGetPlugin(pluginId, version, 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();
}
/// <summary>
/// Gets a plugin's image.
/// </summary>
/// <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}/Image")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
[AllowAnonymous]
public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute] 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));
}
/// <summary>
/// Gets a plugin's status image.
/// </summary>
/// <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}/StatusImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
[AllowAnonymous]
public ActionResult GetPluginStatusImage([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
{
if (!_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
{
return NotFound();
}
// Icons from http://www.fatcow.com/free-icons
var status = plugin!.Manifest.Status;
var type = _pluginManager.GetType();
var stream = type.Assembly.GetManifestResourceStream($"{type.Namespace}.Plugins.{status}.png");
return File(stream, "image/png");
}
/// <summary>
/// Gets a plugin's manifest.
/// </summary>
/// <param name="pluginId">Plugin id.</param>
/// <param name="version">Plugin version.</param>
/// <response code="204">Plugin manifest returned.</response>
/// <response code="404">Plugin not found.</response>
/// <returns>
/// A <see cref="Task" /> that represents the asynchronous operation to get the plugin's manifest.
/// The task result contains an <see cref="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/>
/// when plugin not found.
/// </returns>
[HttpPost("{pluginId}/Manifest")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesFile(MediaTypeNames.Application.Json)]
public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId, [FromRoute] Version? version)
{
if (_pluginManager.TryGetPlugin(pluginId, version, out var plugin))
{
return Ok(plugin!.Manifest);
}
return NotFound();
}
/// <summary>
/// Updates plugin security info.
/// </summary>
/// <param name="pluginSecurityInfo">Plugin security info.</param>
/// <response code="204">Plugin security info updated.</response>
/// <returns>An <see cref="NoContentResult"/>.</returns>
[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();
}
} }
} }

View File

@ -1,4 +1,4 @@
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
@ -32,16 +32,16 @@ namespace Jellyfin.Api.Models
/// </summary> /// </summary>
/// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param>
/// <param name="page">Instance of <see cref="PluginPageInfo"/> 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; Name = page.Name;
EnableInMainMenu = page.EnableInMainMenu; EnableInMainMenu = page.EnableInMainMenu;
MenuSection = page.MenuSection; MenuSection = page.MenuSection;
MenuIcon = page.MenuIcon; MenuIcon = page.MenuIcon;
DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin.Name : page.DisplayName; DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name ?? page.DisplayName : page.DisplayName;
// Don't use "N" because it needs to match Plugin.Id // Don't use "N" because it needs to match Plugin.Id
PluginId = plugin.Id.ToString(); PluginId = plugin?.Id.ToString();
} }
/// <summary> /// <summary>

View File

@ -2,11 +2,16 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Common.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace MediaBrowser.Common 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> /// <summary>
/// An interface to be implemented by the applications hosting a kernel. /// An interface to be implemented by the applications hosting a kernel.
/// </summary> /// </summary>
@ -53,6 +58,11 @@ namespace MediaBrowser.Common
/// <value>The application version.</value> /// <value>The application version.</value>
Version ApplicationVersion { get; } Version ApplicationVersion { get; }
/// <summary>
/// Gets or sets the service provider.
/// </summary>
IServiceProvider ServiceProvider { get; set; }
/// <summary> /// <summary>
/// Gets the application version. /// Gets the application version.
/// </summary> /// </summary>
@ -71,12 +81,6 @@ namespace MediaBrowser.Common
/// </summary> /// </summary>
string ApplicationUserAgentAddress { get; } string ApplicationUserAgentAddress { get; }
/// <summary>
/// Gets the plugins.
/// </summary>
/// <value>The plugins.</value>
IReadOnlyList<IPlugin> Plugins { get; }
/// <summary> /// <summary>
/// Gets all plugin assemblies which implement a custom rest api. /// Gets all plugin assemblies which implement a custom rest api.
/// </summary> /// </summary>
@ -101,6 +105,22 @@ namespace MediaBrowser.Common
/// <returns><see cref="IReadOnlyCollection{T}" />.</returns> /// <returns><see cref="IReadOnlyCollection{T}" />.</returns>
IReadOnlyCollection<T> GetExports<T>(bool manageLifetime = true); 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> /// <summary>
/// Resolves this instance. /// Resolves this instance.
/// </summary> /// </summary>
@ -114,12 +134,6 @@ namespace MediaBrowser.Common
/// <returns>A task.</returns> /// <returns>A task.</returns>
Task Shutdown(); Task Shutdown();
/// <summary>
/// Removes the plugin.
/// </summary>
/// <param name="plugin">The plugin.</param>
void RemovePlugin(IPlugin plugin);
/// <summary> /// <summary>
/// Initializes this instance. /// Initializes this instance.
/// </summary> /// </summary>

View File

@ -7,7 +7,6 @@ using System.Runtime.InteropServices;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.DependencyInjection;
namespace MediaBrowser.Common.Plugins namespace MediaBrowser.Common.Plugins
{ {
@ -64,14 +63,12 @@ namespace MediaBrowser.Common.Plugins
/// <returns>PluginInfo.</returns> /// <returns>PluginInfo.</returns>
public virtual PluginInfo GetPluginInfo() public virtual PluginInfo GetPluginInfo()
{ {
var info = new PluginInfo var info = new PluginInfo(
{ Name,
Name = Name, Version,
Version = Version.ToString(), Description,
Description = Description, Id,
Id = Id.ToString(), CanUninstall);
CanUninstall = CanUninstall
};
return info; return info;
} }

View File

@ -0,0 +1,33 @@
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);
/// <summary>
/// Sets the startup directory creation function.
/// </summary>
/// <param name="directoryCreateFn">The directory function used to create the configuration folder.</param>
void SetStartupInfo(Action<string> directoryCreateFn);
}
}

View File

@ -1,44 +1,36 @@
#pragma warning disable CS1591
using System; using System;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace MediaBrowser.Common.Plugins namespace MediaBrowser.Common.Plugins
{ {
/// <summary> /// <summary>
/// Interface IPlugin. /// Defines the <see cref="IPlugin" />.
/// </summary> /// </summary>
public interface IPlugin public interface IPlugin
{ {
/// <summary> /// <summary>
/// Gets the name of the plugin. /// Gets the name of the plugin.
/// </summary> /// </summary>
/// <value>The name.</value>
string Name { get; } string Name { get; }
/// <summary> /// <summary>
/// Gets the description. /// Gets the Description.
/// </summary> /// </summary>
/// <value>The description.</value>
string Description { get; } string Description { get; }
/// <summary> /// <summary>
/// Gets the unique id. /// Gets the unique id.
/// </summary> /// </summary>
/// <value>The unique id.</value>
Guid Id { get; } Guid Id { get; }
/// <summary> /// <summary>
/// Gets the plugin version. /// Gets the plugin version.
/// </summary> /// </summary>
/// <value>The version.</value>
Version Version { get; } Version Version { get; }
/// <summary> /// <summary>
/// Gets the path to the assembly file. /// Gets the path to the assembly file.
/// </summary> /// </summary>
/// <value>The assembly file path.</value>
string AssemblyFilePath { get; } string AssemblyFilePath { get; }
/// <summary> /// <summary>
@ -49,11 +41,10 @@ namespace MediaBrowser.Common.Plugins
/// <summary> /// <summary>
/// Gets the full path to the data folder, where the plugin can store any miscellaneous files needed. /// Gets the full path to the data folder, where the plugin can store any miscellaneous files needed.
/// </summary> /// </summary>
/// <value>The data folder path.</value>
string DataFolderPath { get; } string DataFolderPath { get; }
/// <summary> /// <summary>
/// Gets the plugin info. /// Gets the <see cref="PluginInfo"/>.
/// </summary> /// </summary>
/// <returns>PluginInfo.</returns> /// <returns>PluginInfo.</returns>
PluginInfo GetPluginInfo(); PluginInfo GetPluginInfo();
@ -63,29 +54,4 @@ namespace MediaBrowser.Common.Plugins
/// </summary> /// </summary>
void OnUninstalling(); 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);
}
} }

View File

@ -0,0 +1,86 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Reflection;
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>
/// <param name="plugin">A <see cref="LocalPlugin"/> if found, otherwise null.</param>
/// <returns>Boolean value signifying the success of the search.</returns>
bool TryGetPlugin(Guid id, Version? version, out LocalPlugin? plugin);
/// <summary>
/// Removes the plugin.
/// </summary>
/// <param name="plugin">The plugin.</param>
/// <returns>Outcome of the operation.</returns>
bool RemovePlugin(LocalPlugin plugin);
}
}

View File

@ -1,6 +1,9 @@
#nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Reflection;
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Common.Plugins namespace MediaBrowser.Common.Plugins
{ {
@ -9,36 +12,48 @@ namespace MediaBrowser.Common.Plugins
/// </summary> /// </summary>
public class LocalPlugin : IEquatable<LocalPlugin> public class LocalPlugin : IEquatable<LocalPlugin>
{ {
private readonly bool _supported;
private Version? _version;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="LocalPlugin"/> class. /// Initializes a new instance of the <see cref="LocalPlugin"/> class.
/// </summary> /// </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> /// <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; Path = path;
DllFiles = new List<string>(); DllFiles = new List<string>();
_supported = isSupported;
Manifest = manifest;
} }
/// <summary> /// <summary>
/// Gets the plugin id. /// Gets the plugin id.
/// </summary> /// </summary>
public Guid Id { get; } public Guid Id => Manifest.Guid;
/// <summary> /// <summary>
/// Gets the plugin name. /// Gets the plugin name.
/// </summary> /// </summary>
public string Name { get; } public string Name => Manifest.Name;
/// <summary> /// <summary>
/// Gets the plugin version. /// Gets the plugin version.
/// </summary> /// </summary>
public Version Version { get; } public Version Version
{
get
{
if (_version == null)
{
_version = Version.Parse(Manifest.Version);
}
return _version;
}
}
/// <summary> /// <summary>
/// Gets the plugin path. /// Gets the plugin path.
@ -51,26 +66,24 @@ namespace MediaBrowser.Common.Plugins
public List<string> DllFiles { get; } public List<string> DllFiles { get; }
/// <summary> /// <summary>
/// == operator. /// Gets or sets the instance of this plugin.
/// </summary> /// </summary>
/// <param name="left">Left item.</param> public IPlugin? Instance { get; set; }
/// <param name="right">Right item.</param>
/// <returns>Comparison result.</returns>
public static bool operator ==(LocalPlugin left, LocalPlugin right)
{
return left.Equals(right);
}
/// <summary> /// <summary>
/// != operator. /// Gets a value indicating whether Jellyfin supports this version of the plugin, and it's enabled.
/// </summary> /// </summary>
/// <param name="left">Left item.</param> public bool IsEnabledAndSupported => _supported && Manifest.Status >= PluginStatus.Active;
/// <param name="right">Right item.</param>
/// <returns>Comparison result.</returns> /// <summary>
public static bool operator !=(LocalPlugin left, LocalPlugin right) /// Gets a value indicating whether the plugin has a manifest.
{ /// </summary>
return !left.Equals(right); public PluginManifest Manifest { get; }
}
/// <summary>
/// Gets or sets a value indicating the assembly of the plugin.
/// </summary>
public Assembly? Assembly { get; set; }
/// <summary> /// <summary>
/// Compare two <see cref="LocalPlugin"/>. /// Compare two <see cref="LocalPlugin"/>.
@ -80,10 +93,15 @@ namespace MediaBrowser.Common.Plugins
/// <returns>Comparison result.</returns> /// <returns>Comparison result.</returns>
public static int Compare(LocalPlugin a, LocalPlugin b) public static int Compare(LocalPlugin a, LocalPlugin b)
{ {
if (a == null || b == null)
{
throw new ArgumentNullException(a == null ? nameof(a) : nameof(b));
}
var compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture); var compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture);
// Id is not equal but name is. // 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); compare = a.Id.CompareTo(b.Id);
} }
@ -91,8 +109,20 @@ namespace MediaBrowser.Common.Plugins
return compare == 0 ? a.Version.CompareTo(b.Version) : compare; 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.Guid, true);
inst.Status = Manifest.Status;
inst.HasImage = !string.IsNullOrEmpty(Manifest.ImageUrl);
return inst;
}
/// <inheritdoc /> /// <inheritdoc />
public override bool Equals(object obj) public override bool Equals(object? obj)
{ {
return obj is LocalPlugin other && this.Equals(other); return obj is LocalPlugin other && this.Equals(other);
} }
@ -104,16 +134,14 @@ namespace MediaBrowser.Common.Plugins
} }
/// <inheritdoc /> /// <inheritdoc />
public bool Equals(LocalPlugin other) public bool Equals(LocalPlugin? other)
{ {
// Do not use == or != for comparison as this class overrides the operators. if (other == null)
if (object.ReferenceEquals(other, null))
{ {
return false; return false;
} }
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && Id.Equals(other.Id) && Version.Equals(other.Version);
&& Id.Equals(other.Id);
} }
} }
} }

View File

@ -0,0 +1,85 @@
#nullable enable
using System;
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Common.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; } = string.Empty;
/// <summary>
/// Gets or sets the changelog information.
/// </summary>
public string Changelog { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the description of the plugin.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the Global Unique Identifier for the plugin.
/// </summary>
#pragma warning disable CA1720 // Identifier contains type name
public Guid Guid { get; set; }
#pragma warning restore CA1720 // Identifier contains type name
/// <summary>
/// Gets or sets the Name of the plugin.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets an overview of the plugin.
/// </summary>
public string Overview { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the owner of the plugin.
/// </summary>
public string Owner { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the compatibility version for the plugin.
/// </summary>
public string TargetAbi { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the upper compatibility version for the plugin.
/// </summary>
public string MaxAbi { get; set; } = string.Empty;
/// <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; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether this plugin should be ignored.
/// </summary>
public PluginStatus Status { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this plugin should automatically update.
/// </summary>
public bool AutoUpdate { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether this plugin has an image.
/// Image must be located in the local plugin folder.
/// </summary>
public string? ImageUrl { get; set; }
}
}

View File

@ -1,4 +1,4 @@
#pragma warning disable CS1591 #nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -9,6 +9,9 @@ using MediaBrowser.Model.Updates;
namespace MediaBrowser.Common.Updates namespace MediaBrowser.Common.Updates
{ {
/// <summary>
/// Defines the <see cref="IInstallationManager" />.
/// </summary>
public interface IInstallationManager : IDisposable public interface IInstallationManager : IDisposable
{ {
/// <summary> /// <summary>
@ -21,12 +24,13 @@ namespace MediaBrowser.Common.Updates
/// </summary> /// </summary>
/// <param name="manifestName">Name of the repository.</param> /// <param name="manifestName">Name of the repository.</param>
/// <param name="manifest">The URL to query.</param> /// <param name="manifest">The URL to query.</param>
/// <param name="filterIncompatible">Filter out incompatible plugins.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task{IReadOnlyList{PackageInfo}}.</returns> /// <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> /// <summary>
/// Gets all available packages. /// Gets all available packages that are supported by this version.
/// </summary> /// </summary>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task{IReadOnlyList{PackageInfo}}.</returns> /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
@ -42,9 +46,11 @@ namespace MediaBrowser.Common.Updates
/// <returns>All plugins matching the requirements.</returns> /// <returns>All plugins matching the requirements.</returns>
IEnumerable<PackageInfo> FilterPackages( IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages, IEnumerable<PackageInfo> availablePackages,
string name = null, string? name = null,
Guid guid = default, #pragma warning disable CA1720 // Identifier contains type name
Version specificVersion = null); Guid? guid = default,
#pragma warning restore CA1720 // Identifier contains type name
Version? specificVersion = null);
/// <summary> /// <summary>
/// Returns all compatible versions ordered from newest to oldest. /// Returns all compatible versions ordered from newest to oldest.
@ -57,13 +63,15 @@ namespace MediaBrowser.Common.Updates
/// <returns>All compatible versions ordered from newest to oldest.</returns> /// <returns>All compatible versions ordered from newest to oldest.</returns>
IEnumerable<InstallationInfo> GetCompatibleVersions( IEnumerable<InstallationInfo> GetCompatibleVersions(
IEnumerable<PackageInfo> availablePackages, IEnumerable<PackageInfo> availablePackages,
string name = null, string? name = null,
Guid guid = default, #pragma warning disable CA1720 // Identifier contains type name
Version minVersion = null, Guid? guid = default,
Version specificVersion = null); #pragma warning restore CA1720 // Identifier contains type name
Version? minVersion = null,
Version? specificVersion = null);
/// <summary> /// <summary>
/// Returns the available plugin updates. /// Returns the available compatible plugin updates.
/// </summary> /// </summary>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The available plugin updates.</returns> /// <returns>The available plugin updates.</returns>
@ -81,7 +89,7 @@ namespace MediaBrowser.Common.Updates
/// Uninstalls a plugin. /// Uninstalls a plugin.
/// </summary> /// </summary>
/// <param name="plugin">The plugin.</param> /// <param name="plugin">The plugin.</param>
void UninstallPlugin(IPlugin plugin); void UninstallPlugin(LocalPlugin plugin);
/// <summary> /// <summary>
/// Cancels the installation. /// Cancels the installation.

View File

@ -1,14 +1,21 @@
#pragma warning disable CS1591
using System; using System;
using MediaBrowser.Model.Updates; using MediaBrowser.Model.Updates;
namespace MediaBrowser.Common.Updates namespace MediaBrowser.Common.Updates
{ {
/// <summary>
/// Defines the <see cref="InstallationEventArgs" />.
/// </summary>
public class InstallationEventArgs : EventArgs public class InstallationEventArgs : EventArgs
{ {
/// <summary>
/// Gets or sets the <see cref="InstallationInfo"/>.
/// </summary>
public InstallationInfo InstallationInfo { get; set; } public InstallationInfo InstallationInfo { get; set; }
/// <summary>
/// Gets or sets the <see cref="VersionInfo"/>.
/// </summary>
public VersionInfo VersionInfo { get; set; } public VersionInfo VersionInfo { get; set; }
} }
} }

View File

@ -1,18 +1,19 @@
using Jellyfin.Data.Events; using Jellyfin.Data.Events;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Controller.Events.Updates namespace MediaBrowser.Controller.Events.Updates
{ {
/// <summary> /// <summary>
/// An event that occurs when a plugin is uninstalled. /// An event that occurs when a plugin is uninstalled.
/// </summary> /// </summary>
public class PluginUninstalledEventArgs : GenericEventArgs<IPlugin> public class PluginUninstalledEventArgs : GenericEventArgs<PluginInfo>
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PluginUninstalledEventArgs"/> class. /// Initializes a new instance of the <see cref="PluginUninstalledEventArgs"/> class.
/// </summary> /// </summary>
/// <param name="arg">The plugin.</param> /// <param name="arg">The plugin.</param>
public PluginUninstalledEventArgs(IPlugin arg) : base(arg) public PluginUninstalledEventArgs(PluginInfo arg) : base(arg)
{ {
} }
} }

View File

@ -19,8 +19,6 @@ namespace MediaBrowser.Controller
{ {
event EventHandler HasUpdateAvailableChanged; event EventHandler HasUpdateAvailableChanged;
IServiceProvider ServiceProvider { get; }
bool CoreStartupHasCompleted { get; } bool CoreStartupHasCompleted { get; }
bool CanLaunchWebBrowser { get; } bool CanLaunchWebBrowser { get; }
@ -122,13 +120,5 @@ namespace MediaBrowser.Controller
string ExpandVirtualPath(string path); string ExpandVirtualPath(string path);
string ReverseVirtualPath(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);
} }
} }

View File

@ -449,5 +449,15 @@ namespace MediaBrowser.Model.Configuration
/// Gets or sets the how many metadata refreshes can run concurrently. /// Gets or sets the how many metadata refreshes can run concurrently.
/// </summary> /// </summary>
public int LibraryMetadataRefreshConcurrency { get; set; } 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; }
} }
} }

View File

@ -1,4 +1,7 @@
#nullable disable #nullable enable
using System;
namespace MediaBrowser.Model.Plugins namespace MediaBrowser.Model.Plugins
{ {
/// <summary> /// <summary>
@ -6,34 +9,46 @@ namespace MediaBrowser.Model.Plugins
/// </summary> /// </summary>
public class PluginInfo 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?.ToString() ?? throw new ArgumentNullException(nameof(version));
Description = description;
Id = id.ToString();
CanUninstall = canUninstall;
}
/// <summary> /// <summary>
/// Gets or sets the name. /// Gets or sets the name.
/// </summary> /// </summary>
/// <value>The name.</value>
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// Gets or sets the version. /// Gets or sets the version.
/// </summary> /// </summary>
/// <value>The version.</value>
public string Version { get; set; } public string Version { get; set; }
/// <summary> /// <summary>
/// Gets or sets the name of the configuration file. /// Gets or sets the name of the configuration file.
/// </summary> /// </summary>
/// <value>The name of the configuration file.</value> public string? ConfigurationFileName { get; set; }
public string ConfigurationFileName { get; set; }
/// <summary> /// <summary>
/// Gets or sets the description. /// Gets or sets the description.
/// </summary> /// </summary>
/// <value>The description.</value>
public string Description { get; set; } public string Description { get; set; }
/// <summary> /// <summary>
/// Gets or sets the unique id. /// Gets or sets the unique id.
/// </summary> /// </summary>
/// <value>The unique id.</value>
public string Id { get; set; } public string Id { get; set; }
/// <summary> /// <summary>
@ -42,9 +57,13 @@ namespace MediaBrowser.Model.Plugins
public bool CanUninstall { get; set; } public bool CanUninstall { get; set; }
/// <summary> /// <summary>
/// Gets or sets the image URL. /// Gets or sets a value indicating whether this plugin has a valid image.
/// </summary> /// </summary>
/// <value>The image URL.</value> public bool HasImage { get; set; }
public string ImageUrl { get; set; }
/// <summary>
/// Gets or sets a value indicating the status of the plugin.
/// </summary>
public PluginStatus Status { get; set; }
} }
} }

View File

@ -0,0 +1,17 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
#pragma warning disable SA1602 // Enumeration items should be documented
namespace MediaBrowser.Model.Plugins
{
/// <summary>
/// Plugin load status.
/// </summary>
public enum PluginStatus
{
RestartRequired = 1,
Active = 0,
Disabled = -1,
NotSupported = -2,
Malfunction = -3,
Superceded = -4
}
}

View File

@ -1,4 +1,4 @@
#nullable disable #nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -9,55 +9,70 @@ namespace MediaBrowser.Model.Updates
/// </summary> /// </summary>
public class PackageInfo public class PackageInfo
{ {
/// <summary>
/// Initializes a new instance of the <see cref="PackageInfo"/> class.
/// </summary>
public PackageInfo()
{
Versions = Array.Empty<VersionInfo>();
Guid = string.Empty;
Category = string.Empty;
Name = string.Empty;
Overview = string.Empty;
Owner = string.Empty;
Description = string.Empty;
}
/// <summary> /// <summary>
/// Gets or sets the name. /// Gets or sets the name.
/// </summary> /// </summary>
/// <value>The name.</value> /// <value>The name.</value>
public string name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// Gets or sets a long description of the plugin containing features or helpful explanations. /// Gets or sets a long description of the plugin containing features or helpful explanations.
/// </summary> /// </summary>
/// <value>The description.</value> /// <value>The description.</value>
public string description { get; set; } public string Description { get; set; }
/// <summary> /// <summary>
/// Gets or sets a short overview of what the plugin does. /// Gets or sets a short overview of what the plugin does.
/// </summary> /// </summary>
/// <value>The overview.</value> /// <value>The overview.</value>
public string overview { get; set; } public string Overview { get; set; }
/// <summary> /// <summary>
/// Gets or sets the owner. /// Gets or sets the owner.
/// </summary> /// </summary>
/// <value>The owner.</value> /// <value>The owner.</value>
public string owner { get; set; } public string Owner { get; set; }
/// <summary> /// <summary>
/// Gets or sets the category. /// Gets or sets the category.
/// </summary> /// </summary>
/// <value>The category.</value> /// <value>The category.</value>
public string category { get; set; } public string Category { get; set; }
/// <summary> /// <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. /// This is used to identify the proper item for automatic updates.
/// </summary> /// </summary>
/// <value>The name.</value> /// <value>The name.</value>
public string guid { get; set; } #pragma warning disable CA1720 // Identifier contains type name
public string Guid { get; set; }
#pragma warning restore CA1720 // Identifier contains type name
/// <summary> /// <summary>
/// Gets or sets the versions. /// Gets or sets the versions.
/// </summary> /// </summary>
/// <value>The versions.</value> /// <value>The versions.</value>
public IList<VersionInfo> versions { get; set; } #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> /// <summary>
/// Initializes a new instance of the <see cref="PackageInfo"/> class. /// Gets or sets the image url for the package.
/// </summary> /// </summary>
public PackageInfo() public string? ImageUrl { get; set; }
{
versions = Array.Empty<VersionInfo>();
}
} }
} }

View File

@ -1,6 +1,6 @@
#nullable disable #nullable enable
using System; using SysVersion = System.Version;
namespace MediaBrowser.Model.Updates namespace MediaBrowser.Model.Updates
{ {
@ -9,68 +9,68 @@ namespace MediaBrowser.Model.Updates
/// </summary> /// </summary>
public class VersionInfo public class VersionInfo
{ {
private Version _version; private SysVersion? _version;
/// <summary> /// <summary>
/// Gets or sets the version. /// Gets or sets the version.
/// </summary> /// </summary>
/// <value>The version.</value> /// <value>The version.</value>
public string version public string Version
{ {
get get => _version == null ? string.Empty : _version.ToString();
{
return _version == null ? string.Empty : _version.ToString();
}
set set => _version = SysVersion.Parse(value);
{
_version = Version.Parse(value);
}
} }
/// <summary> /// <summary>
/// Gets the version as a <see cref="Version"/>. /// Gets the version as a <see cref="SysVersion"/>.
/// </summary> /// </summary>
public Version VersionNumber => _version; public SysVersion VersionNumber => _version ?? new SysVersion(0, 0, 0);
/// <summary> /// <summary>
/// Gets or sets the changelog for this version. /// Gets or sets the changelog for this version.
/// </summary> /// </summary>
/// <value>The changelog.</value> /// <value>The changelog.</value>
public string changelog { get; set; } public string? Changelog { get; set; }
/// <summary> /// <summary>
/// Gets or sets the ABI that this version was built against. /// Gets or sets the ABI that this version was built against.
/// </summary> /// </summary>
/// <value>The target ABI version.</value> /// <value>The target ABI version.</value>
public string targetAbi { get; set; } public string? TargetAbi { get; set; }
/// <summary>
/// Gets or sets the maximum ABI that this version will work with.
/// </summary>
/// <value>The target ABI version.</value>
public string? MaxAbi { get; set; }
/// <summary> /// <summary>
/// Gets or sets the source URL. /// Gets or sets the source URL.
/// </summary> /// </summary>
/// <value>The source URL.</value> /// <value>The source URL.</value>
public string sourceUrl { get; set; } public string? SourceUrl { get; set; }
/// <summary> /// <summary>
/// Gets or sets a checksum for the binary. /// Gets or sets a checksum for the binary.
/// </summary> /// </summary>
/// <value>The checksum.</value> /// <value>The checksum.</value>
public string checksum { get; set; } public string? Checksum { get; set; }
/// <summary> /// <summary>
/// Gets or sets a timestamp of when the binary was built. /// Gets or sets a timestamp of when the binary was built.
/// </summary> /// </summary>
/// <value>The timestamp.</value> /// <value>The timestamp.</value>
public string timestamp { get; set; } public string? Timestamp { get; set; }
/// <summary> /// <summary>
/// Gets or sets the repository name. /// Gets or sets the repository name.
/// </summary> /// </summary>
public string repositoryName { get; set; } public string RepositoryName { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the repository url. /// Gets or sets the repository url.
/// </summary> /// </summary>
public string repositoryUrl { get; set; } public string RepositoryUrl { get; set; } = string.Empty;
} }
} }

View File

@ -1,4 +1,5 @@
Microsoft Visual Studio Solution File, Format Version 12.00 
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16 # Visual Studio Version 16
VisualStudioVersion = 16.0.30503.244 VisualStudioVersion = 16.0.30503.244
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
@ -70,7 +71,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking", "Jell
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking.Tests", "tests\Jellyfin.Networking.Tests\NetworkTesting\Jellyfin.Networking.Tests.csproj", "{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking.Tests", "tests\Jellyfin.Networking.Tests\NetworkTesting\Jellyfin.Networking.Tests.csproj", "{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}"
EndProject 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution