< 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: 579
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 7/22/2025 - 12:11:20 AM Line coverage: 41.5% (27/65) Branch coverage: 25% (8/32) Total lines: 58010/28/2025 - 12:11:27 AM Line coverage: 41.5% (27/65) Branch coverage: 25% (8/32) Total lines: 579 7/22/2025 - 12:11:20 AM Line coverage: 41.5% (27/65) Branch coverage: 25% (8/32) Total lines: 58010/28/2025 - 12:11:27 AM Line coverage: 41.5% (27/65) Branch coverage: 25% (8/32) Total lines: 579

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_CompletedInstallations()100%210%
FilterPackages(...)83.33%6685.71%
UninstallPlugin(...)0%7280%
CancelInstallation(...)0%2040%
Dispose()100%11100%
Dispose(...)75%4485.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;
 2651        private readonly Lock _currentInstallationsLock = new();
 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        {
 2682            _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
 2683            _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
 84
 2685            _logger = logger;
 2686            _applicationHost = appHost;
 2687            _appPaths = appPaths;
 2688            _eventManager = eventManager;
 2689            _httpClientFactory = httpClientFactory;
 2690            _config = config;
 2691            _jsonSerializerOptions = JsonDefaults.Options;
 2692            _pluginManager = pluginManager;
 2693        }
 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 than 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        {
 41226            if (!id.IsEmpty())
 227            {
 40228                availablePackages = availablePackages.Where(x => x.Id.Equals(id));
 229            }
 1230            else if (name is not null)
 231            {
 1232                availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)
 233            }
 234
 41235            if (specificVersion is not null)
 236            {
 0237                availablePackages = availablePackages.Where(x => x.Versions.Any(y => y.VersionNumber.Equals(specificVers
 238            }
 239
 41240            return availablePackages;
 241        }
 242
 243        /// <inheritdoc />
 244        public IEnumerable<InstallationInfo> GetCompatibleVersions(
 245            IEnumerable<PackageInfo> availablePackages,
 246            string? name = null,
 247            Guid id = default,
 248            Version? minVersion = null,
 249            Version? specificVersion = null)
 250        {
 251            var package = FilterPackages(availablePackages, name, id, specificVersion).FirstOrDefault();
 252
 253            // Package not found in repository
 254            if (package is null)
 255            {
 256                yield break;
 257            }
 258
 259            var appVer = _applicationHost.ApplicationVersion;
 260            var availableVersions = package.Versions
 261                .Where(x => string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer);
 262
 263            if (specificVersion is not null)
 264            {
 265                availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
 266            }
 267            else if (minVersion is not null)
 268            {
 269                availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
 270            }
 271
 272            foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
 273            {
 274                yield return new InstallationInfo
 275                {
 276                    Changelog = v.Changelog,
 277                    Id = package.Id,
 278                    Name = package.Name,
 279                    Version = v.VersionNumber,
 280                    SourceUrl = v.SourceUrl,
 281                    Checksum = v.Checksum,
 282                    PackageInfo = package
 283                };
 284            }
 285        }
 286
 287        /// <inheritdoc />
 288        public async Task<IEnumerable<InstallationInfo>> GetAvailablePluginUpdates(CancellationToken cancellationToken =
 289        {
 290            var catalog = await GetAvailablePackages(cancellationToken).ConfigureAwait(false);
 291            return GetAvailablePluginUpdates(catalog);
 292        }
 293
 294        /// <inheritdoc />
 295        public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken)
 296        {
 297            ArgumentNullException.ThrowIfNull(package);
 298
 299            var innerCancellationTokenSource = new CancellationTokenSource();
 300
 301            var tuple = (package, innerCancellationTokenSource);
 302
 303            // Add it to the in-progress list
 304            lock (_currentInstallationsLock)
 305            {
 306                _currentInstallations.Add(tuple);
 307            }
 308
 309            using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancel
 310            var linkedToken = linkedTokenSource.Token;
 311
 312            await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false);
 313
 314            try
 315            {
 316                var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false);
 317
 318                lock (_currentInstallationsLock)
 319                {
 320                    _currentInstallations.Remove(tuple);
 321                }
 322
 323                _completedInstallationsInternal.Add(package);
 324
 325                if (isUpdate)
 326                {
 327                    await _eventManager.PublishAsync(new PluginUpdatedEventArgs(package)).ConfigureAwait(false);
 328                }
 329                else
 330                {
 331                    await _eventManager.PublishAsync(new PluginInstalledEventArgs(package)).ConfigureAwait(false);
 332                }
 333
 334                _applicationHost.NotifyPendingRestart();
 335            }
 336            catch (OperationCanceledException)
 337            {
 338                lock (_currentInstallationsLock)
 339                {
 340                    _currentInstallations.Remove(tuple);
 341                }
 342
 343                _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version);
 344
 345                await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false
 346
 347                throw;
 348            }
 349            catch (Exception ex)
 350            {
 351                _logger.LogError(ex, "Package installation failed");
 352
 353                lock (_currentInstallationsLock)
 354                {
 355                    _currentInstallations.Remove(tuple);
 356                }
 357
 358                await _eventManager.PublishAsync(new InstallationFailedEventArgs
 359                {
 360                    InstallationInfo = package,
 361                    Exception = ex
 362                }).ConfigureAwait(false);
 363
 364                throw;
 365            }
 366            finally
 367            {
 368                // Dispose the progress object and remove the installation from the in-progress list
 369                tuple.innerCancellationTokenSource.Dispose();
 370            }
 371        }
 372
 373        /// <summary>
 374        /// Uninstalls a plugin.
 375        /// </summary>
 376        /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param>
 377        public void UninstallPlugin(LocalPlugin plugin)
 378        {
 0379            if (plugin is null)
 380            {
 0381                return;
 382            }
 383
 0384            if (plugin.Instance?.CanUninstall == false)
 385            {
 0386                _logger.LogWarning("Attempt to delete non removable plugin {PluginName}, ignoring request", plugin.Name)
 0387                return;
 388            }
 389
 0390            plugin.Instance?.OnUninstalling();
 391
 392            // Remove it the quick way for now
 0393            _pluginManager.RemovePlugin(plugin);
 394
 0395            _eventManager.Publish(new PluginUninstalledEventArgs(plugin.GetPluginInfo()));
 396
 0397            _applicationHost.NotifyPendingRestart();
 0398        }
 399
 400        /// <inheritdoc/>
 401        public bool CancelInstallation(Guid id)
 0402        {
 403            lock (_currentInstallationsLock)
 404            {
 0405                var install = _currentInstallations.Find(x => x.Info.Id.Equals(id));
 0406                if (install == default((InstallationInfo, CancellationTokenSource)))
 407                {
 0408                    return false;
 409                }
 410
 0411                install.Token.Cancel();
 0412                _currentInstallations.Remove(install);
 0413                return true;
 414            }
 0415        }
 416
 417        /// <inheritdoc />
 418        public void Dispose()
 419        {
 21420            Dispose(true);
 21421            GC.SuppressFinalize(this);
 21422        }
 423
 424        /// <summary>
 425        /// Releases unmanaged and optionally managed resources.
 426        /// </summary>
 427        /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release
 428        protected virtual void Dispose(bool dispose)
 429        {
 21430            if (dispose)
 21431            {
 432                lock (_currentInstallationsLock)
 433                {
 42434                    foreach (var (info, token) in _currentInstallations)
 435                    {
 0436                        token.Dispose();
 437                    }
 438
 21439                    _currentInstallations.Clear();
 21440                }
 441            }
 21442        }
 443
 444        /// <summary>
 445        /// Merges two sorted lists.
 446        /// </summary>
 447        /// <param name="source">The source <see cref="IList{VersionInfo}"/> instance to merge.</param>
 448        /// <param name="dest">The destination <see cref="IList{VersionInfo}"/> instance to merge with.</param>
 449        private static void MergeSortedList(IList<VersionInfo> source, IList<VersionInfo> dest)
 450        {
 0451            int sLength = source.Count - 1;
 0452            int dLength = dest.Count;
 0453            int s = 0, d = 0;
 0454            var sourceVersion = source[0].VersionNumber;
 0455            var destVersion = dest[0].VersionNumber;
 456
 0457            while (d < dLength)
 458            {
 0459                if (sourceVersion.CompareTo(destVersion) >= 0)
 460                {
 0461                    if (s < sLength)
 462                    {
 0463                        sourceVersion = source[++s].VersionNumber;
 464                    }
 465                    else
 466                    {
 467                        // Append all of destination to the end of source.
 0468                        while (d < dLength)
 469                        {
 0470                            source.Add(dest[d++]);
 471                        }
 472
 0473                        break;
 474                    }
 475                }
 476                else
 477                {
 0478                    source.Insert(s++, dest[d++]);
 0479                    if (d >= dLength)
 480                    {
 481                        break;
 482                    }
 483
 0484                    sLength++;
 0485                    destVersion = dest[d].VersionNumber;
 486                }
 487            }
 0488        }
 489
 490        private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
 491        {
 492            var plugins = _pluginManager.Plugins;
 493            foreach (var plugin in plugins)
 494            {
 495                // Don't auto update when plugin marked not to, or when it's disabled.
 496                if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled)
 497                {
 498                    continue;
 499                }
 500
 501                var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin
 502                var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
 503
 504                if (version is not null && CompletedInstallations.All(x => !x.Id.Equals(version.Id)))
 505                {
 506                    yield return version;
 507                }
 508            }
 509        }
 510
 511        private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken c
 512        {
 513            if (!Path.GetExtension(package.SourceUrl.AsSpan()).Equals(".zip", StringComparison.OrdinalIgnoreCase))
 514            {
 515                _logger.LogError("Only zip packages are supported. {SourceUrl} is not a zip archive.", package.SourceUrl
 516                return;
 517            }
 518
 519            // Always override the passed-in target (which is a file) and figure it out again
 520            string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
 521
 522            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 523                .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
 524            response.EnsureSuccessStatusCode();
 525            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 526
 527            // CA5351: Do Not Use Broken Cryptographic Algorithms
 528#pragma warning disable CA5351
 529            cancellationToken.ThrowIfCancellationRequested();
 530
 531            var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
 532            if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
 533            {
 534                _logger.LogError(
 535                    "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
 536                    package.Name,
 537                    package.Checksum,
 538                    hash);
 539                throw new InvalidDataException("The checksum of the received data doesn't match.");
 540            }
 541
 542            // Version folder as they cannot be overwritten in Windows.
 543            targetDir += "_" + package.Version;
 544
 545            if (Directory.Exists(targetDir))
 546            {
 547                try
 548                {
 549                    Directory.Delete(targetDir, true);
 550                }
 551#pragma warning disable CA1031 // Do not catch general exception types
 552                catch
 553#pragma warning restore CA1031 // Do not catch general exception types
 554                {
 555                    // Ignore any exceptions.
 556                }
 557            }
 558
 559            stream.Position = 0;
 560            ZipFile.ExtractToDirectory(stream, targetDir, true);
 561
 562            // Ensure we create one or populate existing ones with missing data.
 563            await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwai
 564
 565            _pluginManager.ImportPluginFrom(targetDir);
 566        }
 567
 568        private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
 569        {
 570            LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals
 571                  ?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgno
 572
 573            await PerformPackageInstallation(package, plugin?.Manifest.Status ?? PluginStatus.Active, cancellationToken)
 574            _logger.LogInformation("Plugin {Action}: {PluginName} {PluginVersion}", plugin is null ? "installed" : "upda
 575
 576            return plugin is not null;
 577        }
 578    }
 579}