< 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: 584
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 10/18/2025 - 12:10:13 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: 5791/11/2026 - 12:11:48 AM Line coverage: 41.5% (27/65) Branch coverage: 25% (8/32) Total lines: 584 10/18/2025 - 12:10:13 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: 5791/11/2026 - 12:11:48 AM Line coverage: 41.5% (27/65) Branch coverage: 25% (8/32) Total lines: 584

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 (NotSupportedException ex)
 160            {
 161                _logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}",
 162                return Array.Empty<PackageInfo>();
 163            }
 164            catch (HttpRequestException ex)
 165            {
 166                _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
 167                return Array.Empty<PackageInfo>();
 168            }
 169        }
 170
 171        /// <inheritdoc />
 172        public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default
 173        {
 174            var result = new List<PackageInfo>();
 175            foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
 176            {
 177                if (repository.Enabled && repository.Url is not null)
 178                {
 179                    // Where repositories have the same content, the details from the first is taken.
 180                    foreach (var package in await GetPackages(repository.Name ?? "Unnamed Repo", repository.Url, true, c
 181                    {
 182                        var existing = FilterPackages(result, package.Name, package.Id).FirstOrDefault();
 183
 184                        // Remove invalid versions from the valid package.
 185                        for (var i = package.Versions.Count - 1; i >= 0; i--)
 186                        {
 187                            var version = package.Versions[i];
 188
 189                            var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber);
 190                            if (plugin is not null)
 191                            {
 192                                await _pluginManager.PopulateManifest(package, version.VersionNumber, plugin.Path, plugi
 193                            }
 194
 195                            // Remove versions with a target ABI greater than the current application version.
 196                            if (Version.TryParse(version.TargetAbi, out var targetAbi) && _applicationHost.ApplicationVe
 197                            {
 198                                package.Versions.RemoveAt(i);
 199                            }
 200                        }
 201
 202                        // Don't add a package that doesn't have any compatible versions.
 203                        if (package.Versions.Count == 0)
 204                        {
 205                            continue;
 206                        }
 207
 208                        if (existing is not null)
 209                        {
 210                            // Assumption is both lists are ordered, so slot these into the correct place.
 211                            MergeSortedList(existing.Versions, package.Versions);
 212                        }
 213                        else
 214                        {
 215                            result.Add(package);
 216                        }
 217                    }
 218                }
 219            }
 220
 221            return result;
 222        }
 223
 224        /// <inheritdoc />
 225        public IEnumerable<PackageInfo> FilterPackages(
 226            IEnumerable<PackageInfo> availablePackages,
 227            string? name = null,
 228            Guid id = default,
 229            Version? specificVersion = null)
 230        {
 84231            if (!id.IsEmpty())
 232            {
 83233                availablePackages = availablePackages.Where(x => x.Id.Equals(id));
 234            }
 1235            else if (name is not null)
 236            {
 1237                availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)
 238            }
 239
 84240            if (specificVersion is not null)
 241            {
 0242                availablePackages = availablePackages.Where(x => x.Versions.Any(y => y.VersionNumber.Equals(specificVers
 243            }
 244
 84245            return availablePackages;
 246        }
 247
 248        /// <inheritdoc />
 249        public IEnumerable<InstallationInfo> GetCompatibleVersions(
 250            IEnumerable<PackageInfo> availablePackages,
 251            string? name = null,
 252            Guid id = default,
 253            Version? minVersion = null,
 254            Version? specificVersion = null)
 255        {
 256            var package = FilterPackages(availablePackages, name, id, specificVersion).FirstOrDefault();
 257
 258            // Package not found in repository
 259            if (package is null)
 260            {
 261                yield break;
 262            }
 263
 264            var appVer = _applicationHost.ApplicationVersion;
 265            var availableVersions = package.Versions
 266                .Where(x => string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer);
 267
 268            if (specificVersion is not null)
 269            {
 270                availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
 271            }
 272            else if (minVersion is not null)
 273            {
 274                availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
 275            }
 276
 277            foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
 278            {
 279                yield return new InstallationInfo
 280                {
 281                    Changelog = v.Changelog,
 282                    Id = package.Id,
 283                    Name = package.Name,
 284                    Version = v.VersionNumber,
 285                    SourceUrl = v.SourceUrl,
 286                    Checksum = v.Checksum,
 287                    PackageInfo = package
 288                };
 289            }
 290        }
 291
 292        /// <inheritdoc />
 293        public async Task<IEnumerable<InstallationInfo>> GetAvailablePluginUpdates(CancellationToken cancellationToken =
 294        {
 295            var catalog = await GetAvailablePackages(cancellationToken).ConfigureAwait(false);
 296            return GetAvailablePluginUpdates(catalog);
 297        }
 298
 299        /// <inheritdoc />
 300        public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken)
 301        {
 302            ArgumentNullException.ThrowIfNull(package);
 303
 304            var innerCancellationTokenSource = new CancellationTokenSource();
 305
 306            var tuple = (package, innerCancellationTokenSource);
 307
 308            // Add it to the in-progress list
 309            lock (_currentInstallationsLock)
 310            {
 311                _currentInstallations.Add(tuple);
 312            }
 313
 314            using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancel
 315            var linkedToken = linkedTokenSource.Token;
 316
 317            await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false);
 318
 319            try
 320            {
 321                var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false);
 322
 323                lock (_currentInstallationsLock)
 324                {
 325                    _currentInstallations.Remove(tuple);
 326                }
 327
 328                _completedInstallationsInternal.Add(package);
 329
 330                if (isUpdate)
 331                {
 332                    await _eventManager.PublishAsync(new PluginUpdatedEventArgs(package)).ConfigureAwait(false);
 333                }
 334                else
 335                {
 336                    await _eventManager.PublishAsync(new PluginInstalledEventArgs(package)).ConfigureAwait(false);
 337                }
 338
 339                _applicationHost.NotifyPendingRestart();
 340            }
 341            catch (OperationCanceledException)
 342            {
 343                lock (_currentInstallationsLock)
 344                {
 345                    _currentInstallations.Remove(tuple);
 346                }
 347
 348                _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version);
 349
 350                await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false
 351
 352                throw;
 353            }
 354            catch (Exception ex)
 355            {
 356                _logger.LogError(ex, "Package installation failed");
 357
 358                lock (_currentInstallationsLock)
 359                {
 360                    _currentInstallations.Remove(tuple);
 361                }
 362
 363                await _eventManager.PublishAsync(new InstallationFailedEventArgs
 364                {
 365                    InstallationInfo = package,
 366                    Exception = ex
 367                }).ConfigureAwait(false);
 368
 369                throw;
 370            }
 371            finally
 372            {
 373                // Dispose the progress object and remove the installation from the in-progress list
 374                tuple.innerCancellationTokenSource.Dispose();
 375            }
 376        }
 377
 378        /// <summary>
 379        /// Uninstalls a plugin.
 380        /// </summary>
 381        /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param>
 382        public void UninstallPlugin(LocalPlugin plugin)
 383        {
 0384            if (plugin is null)
 385            {
 0386                return;
 387            }
 388
 0389            if (plugin.Instance?.CanUninstall == false)
 390            {
 0391                _logger.LogWarning("Attempt to delete non removable plugin {PluginName}, ignoring request", plugin.Name)
 0392                return;
 393            }
 394
 0395            plugin.Instance?.OnUninstalling();
 396
 397            // Remove it the quick way for now
 0398            _pluginManager.RemovePlugin(plugin);
 399
 0400            _eventManager.Publish(new PluginUninstalledEventArgs(plugin.GetPluginInfo()));
 401
 0402            _applicationHost.NotifyPendingRestart();
 0403        }
 404
 405        /// <inheritdoc/>
 406        public bool CancelInstallation(Guid id)
 0407        {
 408            lock (_currentInstallationsLock)
 409            {
 0410                var install = _currentInstallations.Find(x => x.Info.Id.Equals(id));
 0411                if (install == default((InstallationInfo, CancellationTokenSource)))
 412                {
 0413                    return false;
 414                }
 415
 0416                install.Token.Cancel();
 0417                _currentInstallations.Remove(install);
 0418                return true;
 419            }
 0420        }
 421
 422        /// <inheritdoc />
 423        public void Dispose()
 424        {
 21425            Dispose(true);
 21426            GC.SuppressFinalize(this);
 21427        }
 428
 429        /// <summary>
 430        /// Releases unmanaged and optionally managed resources.
 431        /// </summary>
 432        /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release
 433        protected virtual void Dispose(bool dispose)
 434        {
 21435            if (dispose)
 21436            {
 437                lock (_currentInstallationsLock)
 438                {
 42439                    foreach (var (info, token) in _currentInstallations)
 440                    {
 0441                        token.Dispose();
 442                    }
 443
 21444                    _currentInstallations.Clear();
 21445                }
 446            }
 21447        }
 448
 449        /// <summary>
 450        /// Merges two sorted lists.
 451        /// </summary>
 452        /// <param name="source">The source <see cref="IList{VersionInfo}"/> instance to merge.</param>
 453        /// <param name="dest">The destination <see cref="IList{VersionInfo}"/> instance to merge with.</param>
 454        private static void MergeSortedList(IList<VersionInfo> source, IList<VersionInfo> dest)
 455        {
 0456            int sLength = source.Count - 1;
 0457            int dLength = dest.Count;
 0458            int s = 0, d = 0;
 0459            var sourceVersion = source[0].VersionNumber;
 0460            var destVersion = dest[0].VersionNumber;
 461
 0462            while (d < dLength)
 463            {
 0464                if (sourceVersion.CompareTo(destVersion) >= 0)
 465                {
 0466                    if (s < sLength)
 467                    {
 0468                        sourceVersion = source[++s].VersionNumber;
 469                    }
 470                    else
 471                    {
 472                        // Append all of destination to the end of source.
 0473                        while (d < dLength)
 474                        {
 0475                            source.Add(dest[d++]);
 476                        }
 477
 0478                        break;
 479                    }
 480                }
 481                else
 482                {
 0483                    source.Insert(s++, dest[d++]);
 0484                    if (d >= dLength)
 485                    {
 486                        break;
 487                    }
 488
 0489                    sLength++;
 0490                    destVersion = dest[d].VersionNumber;
 491                }
 492            }
 0493        }
 494
 495        private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
 496        {
 497            var plugins = _pluginManager.Plugins;
 498            foreach (var plugin in plugins)
 499            {
 500                // Don't auto update when plugin marked not to, or when it's disabled.
 501                if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled)
 502                {
 503                    continue;
 504                }
 505
 506                var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin
 507                var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
 508
 509                if (version is not null && CompletedInstallations.All(x => !x.Id.Equals(version.Id)))
 510                {
 511                    yield return version;
 512                }
 513            }
 514        }
 515
 516        private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken c
 517        {
 518            if (!Path.GetExtension(package.SourceUrl.AsSpan()).Equals(".zip", StringComparison.OrdinalIgnoreCase))
 519            {
 520                _logger.LogError("Only zip packages are supported. {SourceUrl} is not a zip archive.", package.SourceUrl
 521                return;
 522            }
 523
 524            // Always override the passed-in target (which is a file) and figure it out again
 525            string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
 526
 527            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 528                .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
 529            response.EnsureSuccessStatusCode();
 530            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 531
 532            // CA5351: Do Not Use Broken Cryptographic Algorithms
 533#pragma warning disable CA5351
 534            cancellationToken.ThrowIfCancellationRequested();
 535
 536            var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
 537            if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
 538            {
 539                _logger.LogError(
 540                    "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}",
 541                    package.Name,
 542                    package.Checksum,
 543                    hash);
 544                throw new InvalidDataException("The checksum of the received data doesn't match.");
 545            }
 546
 547            // Version folder as they cannot be overwritten in Windows.
 548            targetDir += "_" + package.Version;
 549
 550            if (Directory.Exists(targetDir))
 551            {
 552                try
 553                {
 554                    Directory.Delete(targetDir, true);
 555                }
 556#pragma warning disable CA1031 // Do not catch general exception types
 557                catch
 558#pragma warning restore CA1031 // Do not catch general exception types
 559                {
 560                    // Ignore any exceptions.
 561                }
 562            }
 563
 564            stream.Position = 0;
 565            await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken);
 566
 567            // Ensure we create one or populate existing ones with missing data.
 568            await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwai
 569
 570            _pluginManager.ImportPluginFrom(targetDir);
 571        }
 572
 573        private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
 574        {
 575            LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals
 576                  ?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgno
 577
 578            await PerformPackageInstallation(package, plugin?.Manifest.Status ?? PluginStatus.Active, cancellationToken)
 579            _logger.LogInformation("Plugin {Action}: {PluginName} {PluginVersion}", plugin is null ? "installed" : "upda
 580
 581            return plugin is not null;
 582        }
 583    }
 584}