< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Updates.InstallationManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Updates/InstallationManager.cs
Line coverage
41%
Covered lines: 27
Uncovered lines: 38
Coverable lines: 65
Total lines: 580
Line coverage: 41.5%
Branch coverage
25%
Covered branches: 8
Total branches: 32
Branch coverage: 25%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_CompletedInstallations()100%210%
FilterPackages(...)83.33%6.11685.71%
UninstallPlugin(...)0%7280%
CancelInstallation(...)0%2040%
Dispose()100%11100%
Dispose(...)75%4.05485.71%
MergeSortedList(...)0%110100%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/Updates/InstallationManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Generic;
 4using System.IO;
 5using System.IO.Compression;
 6using System.Linq;
 7using System.Net.Http;
 8using System.Net.Http.Json;
 9using System.Security.Cryptography;
 10using System.Text.Json;
 11using System.Threading;
 12using System.Threading.Tasks;
 13using Jellyfin.Data.Events;
 14using Jellyfin.Extensions;
 15using Jellyfin.Extensions.Json;
 16using MediaBrowser.Common.Configuration;
 17using MediaBrowser.Common.Net;
 18using MediaBrowser.Common.Plugins;
 19using MediaBrowser.Common.Updates;
 20using MediaBrowser.Controller;
 21using MediaBrowser.Controller.Configuration;
 22using MediaBrowser.Controller.Events;
 23using MediaBrowser.Controller.Events.Updates;
 24using MediaBrowser.Model.Plugins;
 25using MediaBrowser.Model.Updates;
 26using Microsoft.Extensions.Logging;
 27
 28namespace Emby.Server.Implementations.Updates
 29{
 30    /// <summary>
 31    /// Manages all install, uninstall, and update operations for the system and individual plugins.
 32    /// </summary>
 33    public class InstallationManager : IInstallationManager
 34    {
 35        /// <summary>
 36        /// The logger.
 37        /// </summary>
 38        private readonly ILogger<InstallationManager> _logger;
 39        private readonly IApplicationPaths _appPaths;
 40        private readonly IEventManager _eventManager;
 41        private readonly IHttpClientFactory _httpClientFactory;
 42        private readonly IServerConfigurationManager _config;
 43        private readonly JsonSerializerOptions _jsonSerializerOptions;
 44        private readonly IPluginManager _pluginManager;
 45
 46        /// <summary>
 47        /// Gets the application host.
 48        /// </summary>
 49        /// <value>The application host.</value>
 50        private readonly IServerApplicationHost _applicationHost;
 2751        private readonly object _currentInstallationsLock = new object();
 52
 53        /// <summary>
 54        /// The current installations.
 55        /// </summary>
 56        private readonly List<(InstallationInfo Info, CancellationTokenSource Token)> _currentInstallations;
 57
 58        /// <summary>
 59        /// The completed installations.
 60        /// </summary>
 61        private readonly ConcurrentBag<InstallationInfo> _completedInstallationsInternal;
 62
 63        /// <summary>
 64        /// Initializes a new instance of the <see cref="InstallationManager"/> class.
 65        /// </summary>
 66        /// <param name="logger">The <see cref="ILogger{InstallationManager}"/>.</param>
 67        /// <param name="appHost">The <see cref="IServerApplicationHost"/>.</param>
 68        /// <param name="appPaths">The <see cref="IApplicationPaths"/>.</param>
 69        /// <param name="eventManager">The <see cref="IEventManager"/>.</param>
 70        /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
 71        /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
 72        /// <param name="pluginManager">The <see cref="IPluginManager"/>.</param>
 73        public InstallationManager(
 74            ILogger<InstallationManager> logger,
 75            IServerApplicationHost appHost,
 76            IApplicationPaths appPaths,
 77            IEventManager eventManager,
 78            IHttpClientFactory httpClientFactory,
 79            IServerConfigurationManager config,
 80            IPluginManager pluginManager)
 81        {
 2782            _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
 2783            _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
 84
 2785            _logger = logger;
 2786            _applicationHost = appHost;
 2787            _appPaths = appPaths;
 2788            _eventManager = eventManager;
 2789            _httpClientFactory = httpClientFactory;
 2790            _config = config;
 2791            _jsonSerializerOptions = JsonDefaults.Options;
 2792            _pluginManager = pluginManager;
 2793        }
 94
 95        /// <inheritdoc />
 096        public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
 97
 98        /// <inheritdoc />
 99        public async Task<PackageInfo[]> GetPackages(string manifestName, string manifest, bool filterIncompatible, Canc
 100        {
 101            try
 102            {
 103                PackageInfo[]? packages = await _httpClientFactory.CreateClient(NamedClient.Default)
 104                        .GetFromJsonAsync<PackageInfo[]>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).C
 105
 106                if (packages is null)
 107                {
 108                    return Array.Empty<PackageInfo>();
 109                }
 110
 111                var minimumVersion = new Version(0, 0, 0, 1);
 112                // Store the repository and repository url with each version, as they may be spread apart.
 113                foreach (var entry in packages)
 114                {
 115                    for (int a = entry.Versions.Count - 1; a >= 0; a--)
 116                    {
 117                        var ver = entry.Versions[a];
 118                        ver.RepositoryName = manifestName;
 119                        ver.RepositoryUrl = manifest;
 120
 121                        if (!filterIncompatible)
 122                        {
 123                            continue;
 124                        }
 125
 126                        if (!Version.TryParse(ver.TargetAbi, out var targetAbi))
 127                        {
 128                            targetAbi = minimumVersion;
 129                        }
 130
 131                        // Only show plugins that are greater than or equal to targetAbi.
 132                        if (_applicationHost.ApplicationVersion >= targetAbi)
 133                        {
 134                            continue;
 135                        }
 136
 137                        // Not compatible with this version so remove it.
 138                        entry.Versions.Remove(ver);
 139                    }
 140                }
 141
 142                return packages;
 143            }
 144            catch (IOException ex)
 145            {
 146                _logger.LogError(ex, "Cannot locate the plugin manifest {Manifest}", manifest);
 147                return Array.Empty<PackageInfo>();
 148            }
 149            catch (JsonException ex)
 150            {
 151                _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
 152                return Array.Empty<PackageInfo>();
 153            }
 154            catch (UriFormatException ex)
 155            {
 156                _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}
 157                return Array.Empty<PackageInfo>();
 158            }
 159            catch (HttpRequestException ex)
 160            {
 161                _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
 162                return Array.Empty<PackageInfo>();
 163            }
 164        }
 165
 166        /// <inheritdoc />
 167        public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default
 168        {
 169            var result = new List<PackageInfo>();
 170            foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
 171            {
 172                if (repository.Enabled && repository.Url is not null)
 173                {
 174                    // Where repositories have the same content, the details from the first is taken.
 175                    foreach (var package in await GetPackages(repository.Name ?? "Unnamed Repo", repository.Url, true, c
 176                    {
 177                        var existing = FilterPackages(result, package.Name, package.Id).FirstOrDefault();
 178
 179                        // Remove invalid versions from the valid package.
 180                        for (var i = package.Versions.Count - 1; i >= 0; i--)
 181                        {
 182                            var version = package.Versions[i];
 183
 184                            var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber);
 185                            if (plugin is not null)
 186                            {
 187                                await _pluginManager.PopulateManifest(package, version.VersionNumber, plugin.Path, plugi
 188                            }
 189
 190                            // Remove versions with a target ABI greater then the current application version.
 191                            if (Version.TryParse(version.TargetAbi, out var targetAbi) && _applicationHost.ApplicationVe
 192                            {
 193                                package.Versions.RemoveAt(i);
 194                            }
 195                        }
 196
 197                        // Don't add a package that doesn't have any compatible versions.
 198                        if (package.Versions.Count == 0)
 199                        {
 200                            continue;
 201                        }
 202
 203                        if (existing is not null)
 204                        {
 205                            // Assumption is both lists are ordered, so slot these into the correct place.
 206                            MergeSortedList(existing.Versions, package.Versions);
 207                        }
 208                        else
 209                        {
 210                            result.Add(package);
 211                        }
 212                    }
 213                }
 214            }
 215
 216            return result;
 217        }
 218
 219        /// <inheritdoc />
 220        public IEnumerable<PackageInfo> FilterPackages(
 221            IEnumerable<PackageInfo> availablePackages,
 222            string? name = null,
 223            Guid id = default,
 224            Version? specificVersion = null)
 225        {
 9226            if (name is not null)
 227            {
 8228                availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)
 229            }
 230
 9231            if (!id.IsEmpty())
 232            {
 8233                availablePackages = availablePackages.Where(x => x.Id.Equals(id));
 234            }
 235
 9236            if (specificVersion is not null)
 237            {
 0238                availablePackages = availablePackages.Where(x => x.Versions.Any(y => y.VersionNumber.Equals(specificVers
 239            }
 240
 9241            return availablePackages;
 242        }
 243
 244        /// <inheritdoc />
 245        public IEnumerable<InstallationInfo> GetCompatibleVersions(
 246            IEnumerable<PackageInfo> availablePackages,
 247            string? name = null,
 248            Guid id = default,
 249            Version? minVersion = null,
 250            Version? specificVersion = null)
 251        {
 252            var package = FilterPackages(availablePackages, name, id, specificVersion).FirstOrDefault();
 253
 254            // Package not found in repository
 255            if (package is null)
 256            {
 257                yield break;
 258            }
 259
 260            var appVer = _applicationHost.ApplicationVersion;
 261            var availableVersions = package.Versions
 262                .Where(x => string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer);
 263
 264            if (specificVersion is not null)
 265            {
 266                availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
 267            }
 268            else if (minVersion is not null)
 269            {
 270                availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
 271            }
 272
 273            foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
 274            {
 275                yield return new InstallationInfo
 276                {
 277                    Changelog = v.Changelog,
 278                    Id = package.Id,
 279                    Name = package.Name,
 280                    Version = v.VersionNumber,
 281                    SourceUrl = v.SourceUrl,
 282                    Checksum = v.Checksum,
 283                    PackageInfo = package
 284                };
 285            }
 286        }
 287
 288        /// <inheritdoc />
 289        public async Task<IEnumerable<InstallationInfo>> GetAvailablePluginUpdates(CancellationToken cancellationToken =
 290        {
 291            var catalog = await GetAvailablePackages(cancellationToken).ConfigureAwait(false);
 292            return GetAvailablePluginUpdates(catalog);
 293        }
 294
 295        /// <inheritdoc />
 296        public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken)
 297        {
 298            ArgumentNullException.ThrowIfNull(package);
 299
 300            var innerCancellationTokenSource = new CancellationTokenSource();
 301
 302            var tuple = (package, innerCancellationTokenSource);
 303
 304            // Add it to the in-progress list
 305            lock (_currentInstallationsLock)
 306            {
 307                _currentInstallations.Add(tuple);
 308            }
 309
 310            using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancel
 311            var linkedToken = linkedTokenSource.Token;
 312
 313            await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false);
 314
 315            try
 316            {
 317                var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false);
 318
 319                lock (_currentInstallationsLock)
 320                {
 321                    _currentInstallations.Remove(tuple);
 322                }
 323
 324                _completedInstallationsInternal.Add(package);
 325
 326                if (isUpdate)
 327                {
 328                    await _eventManager.PublishAsync(new PluginUpdatedEventArgs(package)).ConfigureAwait(false);
 329                }
 330                else
 331                {
 332                    await _eventManager.PublishAsync(new PluginInstalledEventArgs(package)).ConfigureAwait(false);
 333                }
 334
 335                _applicationHost.NotifyPendingRestart();
 336            }
 337            catch (OperationCanceledException)
 338            {
 339                lock (_currentInstallationsLock)
 340                {
 341                    _currentInstallations.Remove(tuple);
 342                }
 343
 344                _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version);
 345
 346                await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false
 347
 348                throw;
 349            }
 350            catch (Exception ex)
 351            {
 352                _logger.LogError(ex, "Package installation failed");
 353
 354                lock (_currentInstallationsLock)
 355                {
 356                    _currentInstallations.Remove(tuple);
 357                }
 358
 359                await _eventManager.PublishAsync(new InstallationFailedEventArgs
 360                {
 361                    InstallationInfo = package,
 362                    Exception = ex
 363                }).ConfigureAwait(false);
 364
 365                throw;
 366            }
 367            finally
 368            {
 369                // Dispose the progress object and remove the installation from the in-progress list
 370                tuple.innerCancellationTokenSource.Dispose();
 371            }
 372        }
 373
 374        /// <summary>
 375        /// Uninstalls a plugin.
 376        /// </summary>
 377        /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param>
 378        public void UninstallPlugin(LocalPlugin plugin)
 379        {
 0380            if (plugin is null)
 381            {
 0382                return;
 383            }
 384
 0385            if (plugin.Instance?.CanUninstall == false)
 386            {
 0387                _logger.LogWarning("Attempt to delete non removable plugin {PluginName}, ignoring request", plugin.Name)
 0388                return;
 389            }
 390
 0391            plugin.Instance?.OnUninstalling();
 392
 393            // Remove it the quick way for now
 0394            _pluginManager.RemovePlugin(plugin);
 395
 0396            _eventManager.Publish(new PluginUninstalledEventArgs(plugin.GetPluginInfo()));
 397
 0398            _applicationHost.NotifyPendingRestart();
 0399        }
 400
 401        /// <inheritdoc/>
 402        public bool CancelInstallation(Guid id)
 403        {
 0404            lock (_currentInstallationsLock)
 405            {
 0406                var install = _currentInstallations.Find(x => x.Info.Id.Equals(id));
 0407                if (install == default((InstallationInfo, CancellationTokenSource)))
 408                {
 0409                    return false;
 410                }
 411
 0412                install.Token.Cancel();
 0413                _currentInstallations.Remove(install);
 0414                return true;
 415            }
 0416        }
 417
 418        /// <inheritdoc />
 419        public void Dispose()
 420        {
 22421            Dispose(true);
 22422            GC.SuppressFinalize(this);
 22423        }
 424
 425        /// <summary>
 426        /// Releases unmanaged and optionally managed resources.
 427        /// </summary>
 428        /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release
 429        protected virtual void Dispose(bool dispose)
 430        {
 22431            if (dispose)
 432            {
 22433                lock (_currentInstallationsLock)
 434                {
 44435                    foreach (var (info, token) in _currentInstallations)
 436                    {
 0437                        token.Dispose();
 438                    }
 439
 22440                    _currentInstallations.Clear();
 22441                }
 442            }
 22443        }
 444
 445        /// <summary>
 446        /// Merges two sorted lists.
 447        /// </summary>
 448        /// <param name="source">The source <see cref="IList{VersionInfo}"/> instance to merge.</param>
 449        /// <param name="dest">The destination <see cref="IList{VersionInfo}"/> instance to merge with.</param>
 450        private static void MergeSortedList(IList<VersionInfo> source, IList<VersionInfo> dest)
 451        {
 0452            int sLength = source.Count - 1;
 0453            int dLength = dest.Count;
 0454            int s = 0, d = 0;
 0455            var sourceVersion = source[0].VersionNumber;
 0456            var destVersion = dest[0].VersionNumber;
 457
 0458            while (d < dLength)
 459            {
 0460                if (sourceVersion.CompareTo(destVersion) >= 0)
 461                {
 0462                    if (s < sLength)
 463                    {
 0464                        sourceVersion = source[++s].VersionNumber;
 465                    }
 466                    else
 467                    {
 468                        // Append all of destination to the end of source.
 0469                        while (d < dLength)
 470                        {
 0471                            source.Add(dest[d++]);
 472                        }
 473
 0474                        break;
 475                    }
 476                }
 477                else
 478                {
 0479                    source.Insert(s++, dest[d++]);
 0480                    if (d >= dLength)
 481                    {
 482                        break;
 483                    }
 484
 0485                    sLength++;
 0486                    destVersion = dest[d].VersionNumber;
 487                }
 488            }
 0489        }
 490
 491        private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
 492        {
 493            var plugins = _pluginManager.Plugins;
 494            foreach (var plugin in plugins)
 495            {
 496                // Don't auto update when plugin marked not to, or when it's disabled.
 497                if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled)
 498                {
 499                    continue;
 500                }
 501
 502                var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin
 503                var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
 504
 505                if (version is not null && CompletedInstallations.All(x => !x.Id.Equals(version.Id)))
 506                {
 507                    yield return version;
 508                }
 509            }
 510        }
 511
 512        private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken c
 513        {
 514            if (!Path.GetExtension(package.SourceUrl.AsSpan()).Equals(".zip", StringComparison.OrdinalIgnoreCase))
 515            {
 516                _logger.LogError("Only zip packages are supported. {SourceUrl} is not a zip archive.", package.SourceUrl
 517                return;
 518            }
 519
 520            // Always override the passed-in target (which is a file) and figure it out again
 521            string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
 522
 523            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 524                .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
 525            response.EnsureSuccessStatusCode();
 526            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 527
 528            // CA5351: Do Not Use Broken Cryptographic Algorithms
 529#pragma warning disable CA5351
 530            cancellationToken.ThrowIfCancellationRequested();
 531
 532            var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
 533            if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
 534            {
 535                _logger.LogError(
 536                    "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
 537                    package.Name,
 538                    package.Checksum,
 539                    hash);
 540                throw new InvalidDataException("The checksum of the received data doesn't match.");
 541            }
 542
 543            // Version folder as they cannot be overwritten in Windows.
 544            targetDir += "_" + package.Version;
 545
 546            if (Directory.Exists(targetDir))
 547            {
 548                try
 549                {
 550                    Directory.Delete(targetDir, true);
 551                }
 552#pragma warning disable CA1031 // Do not catch general exception types
 553                catch
 554#pragma warning restore CA1031 // Do not catch general exception types
 555                {
 556                    // Ignore any exceptions.
 557                }
 558            }
 559
 560            stream.Position = 0;
 561            ZipFile.ExtractToDirectory(stream, targetDir, true);
 562
 563            // Ensure we create one or populate existing ones with missing data.
 564            await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwai
 565
 566            _pluginManager.ImportPluginFrom(targetDir);
 567        }
 568
 569        private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
 570        {
 571            LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals
 572                  ?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgno
 573
 574            await PerformPackageInstallation(package, plugin?.Manifest.Status ?? PluginStatus.Active, cancellationToken)
 575            _logger.LogInformation("Plugin {Action}: {PluginName} {PluginVersion}", plugin is null ? "installed" : "upda
 576
 577            return plugin is not null;
 578        }
 579    }
 580}