< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.EntryPoints.LibraryChangedNotifier
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
Line coverage
49%
Covered lines: 88
Uncovered lines: 89
Coverable lines: 177
Total lines: 415
Line coverage: 49.7%
Branch coverage
41%
Covered branches: 31
Total branches: 74
Branch coverage: 41.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/12/2026 - 12:15:57 AM Line coverage: 31.6% (55/174) Branch coverage: 32.2% (20/62) Total lines: 40210/29/2026 - 12:17:11 AM Line coverage: 49.7% (88/177) Branch coverage: 41.8% (31/74) Total lines: 41510/30/2026 - 12:16:57 AM Line coverage: 49.7% (88/177) Branch coverage: 43.2% (32/74) Total lines: 41510/31/2026 - 12:17:46 AM Line coverage: 49.7% (88/177) Branch coverage: 41.8% (31/74) Total lines: 415 5/12/2026 - 12:15:57 AM Line coverage: 31.6% (55/174) Branch coverage: 32.2% (20/62) Total lines: 40210/29/2026 - 12:17:11 AM Line coverage: 49.7% (88/177) Branch coverage: 41.8% (31/74) Total lines: 41510/30/2026 - 12:16:57 AM Line coverage: 49.7% (88/177) Branch coverage: 43.2% (32/74) Total lines: 41510/31/2026 - 12:17:46 AM Line coverage: 49.7% (88/177) Branch coverage: 41.8% (31/74) Total lines: 415

Coverage delta

Coverage delta 19 -19

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
StartAsync(...)100%11100%
StopAsync(...)100%11100%
OnProviderRefreshProgress(...)8.33%1131211.11%
OnProviderRefreshStarted(...)100%11100%
OnProviderRefreshCompleted(...)100%11100%
EnableRefreshMessage(...)58.33%1212100%
OnLibraryItemAdded(...)100%11100%
OnLibraryItemUpdated(...)100%11100%
OnLibraryItemRemoved(...)100%210%
OnLibraryChange(...)80%101083.33%
LibraryUpdateTimerCallback()66.66%121296.15%
SendChangeNotifications()25%9430.43%
GetLibraryUpdateInfo(...)100%210%
FilterItem(...)50%12860%
GetTopParentIds(...)0%4260%
TranslatePhysicalItemToUserLibrary(...)0%7280%
Dispose()50%22100%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Generic;
 4using System.Globalization;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Jellyfin.Data.Events;
 9using Jellyfin.Database.Implementations.Entities;
 10using Jellyfin.Extensions;
 11using MediaBrowser.Controller.Channels;
 12using MediaBrowser.Controller.Configuration;
 13using MediaBrowser.Controller.Entities;
 14using MediaBrowser.Controller.Entities.Audio;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Controller.Providers;
 17using MediaBrowser.Controller.Session;
 18using MediaBrowser.Model.Entities;
 19using MediaBrowser.Model.Session;
 20using Microsoft.Extensions.Hosting;
 21using Microsoft.Extensions.Logging;
 22
 23namespace Emby.Server.Implementations.EntryPoints;
 24
 25/// <summary>
 26/// A <see cref="IHostedService"/> responsible for notifying users when libraries are updated.
 27/// </summary>
 28public sealed class LibraryChangedNotifier : IHostedService, IDisposable
 29{
 30    // A batch holds a live reference to every item it names, so it has to stay small enough that a
 31    // library scan - which changes items faster than any batch window closes - cannot grow it without
 32    // bound. Reached only by a scan; interactive use closes a batch on the window long before this.
 33    internal const int MaxBatchSize = 2000;
 34
 35    private readonly ILibraryManager _libraryManager;
 36    private readonly IServerConfigurationManager _configurationManager;
 37    private readonly IProviderManager _providerManager;
 38    private readonly ISessionManager _sessionManager;
 39    private readonly IUserManager _userManager;
 40    private readonly ILogger<LibraryChangedNotifier> _logger;
 41
 2942    private readonly Lock _libraryChangedSyncLock = new();
 2943    private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
 2944    private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
 2945    private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
 2946    private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
 2947    private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
 2948    private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
 49
 50    private Timer? _libraryUpdateTimer;
 51
 52    /// <summary>
 53    /// Initializes a new instance of the <see cref="LibraryChangedNotifier"/> class.
 54    /// </summary>
 55    /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 56    /// <param name="configurationManager">The <see cref="IServerConfigurationManager"/>.</param>
 57    /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
 58    /// <param name="userManager">The <see cref="IUserManager"/>.</param>
 59    /// <param name="logger">The <see cref="ILogger"/>.</param>
 60    /// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
 61    public LibraryChangedNotifier(
 62        ILibraryManager libraryManager,
 63        IServerConfigurationManager configurationManager,
 64        ISessionManager sessionManager,
 65        IUserManager userManager,
 66        ILogger<LibraryChangedNotifier> logger,
 67        IProviderManager providerManager)
 68    {
 2969        _libraryManager = libraryManager;
 2970        _configurationManager = configurationManager;
 2971        _sessionManager = sessionManager;
 2972        _userManager = userManager;
 2973        _logger = logger;
 2974        _providerManager = providerManager;
 2975    }
 76
 77    /// <inheritdoc />
 78    public Task StartAsync(CancellationToken cancellationToken)
 79    {
 2980        _libraryManager.ItemAdded += OnLibraryItemAdded;
 2981        _libraryManager.ItemUpdated += OnLibraryItemUpdated;
 2982        _libraryManager.ItemRemoved += OnLibraryItemRemoved;
 83
 2984        _providerManager.RefreshCompleted += OnProviderRefreshCompleted;
 2985        _providerManager.RefreshStarted += OnProviderRefreshStarted;
 2986        _providerManager.RefreshProgress += OnProviderRefreshProgress;
 87
 2988        return Task.CompletedTask;
 89    }
 90
 91    /// <inheritdoc />
 92    public Task StopAsync(CancellationToken cancellationToken)
 93    {
 2994        _libraryManager.ItemAdded -= OnLibraryItemAdded;
 2995        _libraryManager.ItemUpdated -= OnLibraryItemUpdated;
 2996        _libraryManager.ItemRemoved -= OnLibraryItemRemoved;
 97
 2998        _providerManager.RefreshCompleted -= OnProviderRefreshCompleted;
 2999        _providerManager.RefreshStarted -= OnProviderRefreshStarted;
 29100        _providerManager.RefreshProgress -= OnProviderRefreshProgress;
 101
 29102        return Task.CompletedTask;
 103    }
 104
 105    private void OnProviderRefreshProgress(object? sender, GenericEventArgs<Tuple<BaseItem, double>> e)
 106    {
 101107        var item = e.Argument.Item1;
 108
 101109        if (!EnableRefreshMessage(item))
 110        {
 101111            return;
 112        }
 113
 0114        var progress = e.Argument.Item2;
 115
 0116        if (_lastProgressMessageTimes.TryGetValue(item.Id, out var lastMessageSendTime))
 117        {
 0118            if (progress > 0 && progress < 100 && (DateTime.UtcNow - lastMessageSendTime).TotalMilliseconds < 1000)
 119            {
 0120                return;
 121            }
 122        }
 123
 0124        _lastProgressMessageTimes.AddOrUpdate(item.Id, _ => DateTime.UtcNow, (_, _) => DateTime.UtcNow);
 125
 0126        var dict = new Dictionary<string, string>();
 0127        dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture);
 0128        dict["Progress"] = progress.ToString(CultureInfo.InvariantCulture);
 129
 130        try
 131        {
 0132            _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, dict, CancellationToken.None)
 0133        }
 0134        catch
 135        {
 0136        }
 137
 0138        var collectionFolders = _libraryManager.GetCollectionFolders(item);
 139
 0140        foreach (var collectionFolder in collectionFolders)
 141        {
 0142            var collectionFolderDict = new Dictionary<string, string>
 0143            {
 0144                ["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
 0145                ["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture)
 0146            };
 147
 148            try
 149            {
 0150                _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, collectionFolderDict, Can
 0151            }
 0152            catch
 153            {
 0154            }
 155        }
 0156    }
 157
 158    private void OnProviderRefreshStarted(object? sender, GenericEventArgs<BaseItem> e)
 20159        => OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e
 160
 161    private void OnProviderRefreshCompleted(object? sender, GenericEventArgs<BaseItem> e)
 162    {
 20163        OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Ar
 164
 20165        _lastProgressMessageTimes.TryRemove(e.Argument.Id, out _);
 20166    }
 167
 168    private static bool EnableRefreshMessage(BaseItem item)
 101169        => item is Folder { IsRoot: false, IsTopParent: true }
 101170            and not (AggregateFolder or UserRootFolder or UserView or Channel);
 171
 172    private void OnLibraryItemAdded(object? sender, ItemChangeEventArgs e)
 11173        => OnLibraryChange(e.Item, e.Parent, _itemsAdded, _foldersAddedTo);
 174
 175    private void OnLibraryItemUpdated(object? sender, ItemChangeEventArgs e)
 2183176        => OnLibraryChange(e.Item, e.Parent, _itemsUpdated, null);
 177
 178    private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
 0179        => OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
 180
 181    private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, 
 182    {
 2194183        if (!FilterItem(item))
 184        {
 0185            return;
 186        }
 187
 188        lock (_libraryChangedSyncLock)
 189        {
 190            // The window runs from the first change of a batch and is never extended. Extending it on
 191            // every change would keep a library scan's batch open for the whole scan, and the batch
 192            // holds the items it names alive, so it would grow to the size of the library.
 2194193            if (_libraryUpdateTimer is null)
 194            {
 29195                var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
 29196                _libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSp
 197            }
 198
 2194199            if (foldersList is not null && parent is Folder folder)
 200            {
 6201                foldersList[folder.Id] = folder;
 202            }
 203
 2194204            itemsList[item.Id] = item;
 205
 206            // A window long enough to cover a burst still has to give way once the batch is large
 207            // enough to be worth sending on its own.
 2194208            if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize)
 209            {
 1210                _libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
 211            }
 2194212        }
 2194213    }
 214
 215    private async void LibraryUpdateTimerCallback(object? state)
 2216    {
 217        List<Folder> foldersAddedTo;
 218        List<Folder> foldersRemovedFrom;
 219        List<BaseItem> itemsUpdated;
 220        List<BaseItem> itemsAdded;
 221        List<BaseItem> itemsRemoved;
 222        lock (_libraryChangedSyncLock)
 223        {
 2224            foldersAddedTo = _foldersAddedTo.Values.ToList();
 2225            foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
 226
 2227            itemsUpdated = _itemsUpdated
 2228                .Where(e => !_itemsAdded.ContainsKey(e.Key))
 2229                .Select(e => e.Value)
 2230                .ToList();
 231
 2232            itemsAdded = _itemsAdded.Values.ToList();
 2233            itemsRemoved = _itemsRemoved.Values.ToList();
 234
 2235            if (_libraryUpdateTimer is not null)
 236            {
 2237                _libraryUpdateTimer.Dispose();
 2238                _libraryUpdateTimer = null;
 239            }
 240
 2241            _itemsAdded.Clear();
 2242            _itemsRemoved.Clear();
 2243            _itemsUpdated.Clear();
 2244            _foldersAddedTo.Clear();
 2245            _foldersRemovedFrom.Clear();
 2246        }
 247
 2248        if (itemsAdded.Count == 0
 2249            && itemsUpdated.Count == 0
 2250            && itemsRemoved.Count == 0
 2251            && foldersAddedTo.Count == 0
 2252            && foldersRemovedFrom.Count == 0)
 253        {
 0254            return;
 255        }
 256
 2257        await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, Cancel
 2258    }
 259
 260    private async Task SendChangeNotifications(
 261        List<BaseItem> itemsAdded,
 262        List<BaseItem> itemsUpdated,
 263        List<BaseItem> itemsRemoved,
 264        List<Folder> foldersAddedTo,
 265        List<Folder> foldersRemovedFrom,
 266        CancellationToken cancellationToken)
 267    {
 2268        var userIds = _sessionManager.Sessions
 2269            .Select(i => i.UserId)
 2270            .Where(i => !i.IsEmpty())
 2271            .Distinct()
 2272            .ToArray();
 273
 4274        foreach (var userId in userIds)
 275        {
 276            LibraryUpdateInfo info;
 277
 278            try
 279            {
 0280                info = GetLibraryUpdateInfo(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, 
 0281            }
 0282            catch (Exception ex)
 283            {
 0284                _logger.LogError(ex, "Error in GetLibraryUpdateInfo");
 0285                return;
 286            }
 287
 0288            if (info.IsEmpty)
 289            {
 290                continue;
 291            }
 292
 293            try
 294            {
 0295                await _sessionManager.SendMessageToUserSessions(
 0296                        new List<Guid> { userId },
 0297                        SessionMessageType.LibraryChanged,
 0298                        info,
 0299                        cancellationToken)
 0300                    .ConfigureAwait(false);
 0301            }
 0302            catch (Exception ex)
 303            {
 0304                _logger.LogError(ex, "Error sending LibraryChanged message");
 0305            }
 306        }
 2307    }
 308
 309    private LibraryUpdateInfo GetLibraryUpdateInfo(
 310        List<BaseItem> itemsAdded,
 311        List<BaseItem> itemsUpdated,
 312        List<BaseItem> itemsRemoved,
 313        List<Folder> foldersAddedTo,
 314        List<Folder> foldersRemovedFrom,
 315        Guid userId)
 316    {
 0317        var user = _userManager.GetUserById(userId);
 0318        ArgumentNullException.ThrowIfNull(user);
 319
 0320        var newAndRemoved = new List<BaseItem>();
 0321        newAndRemoved.AddRange(foldersAddedTo);
 0322        newAndRemoved.AddRange(foldersRemovedFrom);
 323
 0324        var allUserRootChildren = _libraryManager.GetUserRootFolder()
 0325            .GetChildren(user, true)
 0326            .OfType<Folder>()
 0327            .ToList();
 328
 0329        return new LibraryUpdateInfo
 0330        {
 0331            ItemsAdded = itemsAdded.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
 0332                .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
 0333                .Distinct()
 0334                .ToArray(),
 0335            ItemsUpdated = itemsUpdated.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
 0336                .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
 0337                .Distinct()
 0338                .ToArray(),
 0339            ItemsRemoved = itemsRemoved.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user, true))
 0340                .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
 0341                .Distinct()
 0342                .ToArray(),
 0343            FoldersAddedTo = foldersAddedTo.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
 0344                .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
 0345                .Distinct()
 0346                .ToArray(),
 0347            FoldersRemovedFrom = foldersRemovedFrom.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
 0348                .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
 0349                .Distinct()
 0350                .ToArray(),
 0351            CollectionFolders = GetTopParentIds(newAndRemoved, allUserRootChildren).ToArray()
 0352        };
 353    }
 354
 355    private static bool FilterItem(BaseItem item)
 356    {
 2194357        if (!item.IsFolder && !item.HasPathProtocol)
 358        {
 0359            return false;
 360        }
 361
 2194362        if (item is IItemByName && item is not MusicArtist)
 363        {
 0364            return false;
 365        }
 366
 2194367        return item.SourceType == SourceType.Library;
 368    }
 369
 370    private static IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren)
 371    {
 0372        var list = new List<string>();
 373
 0374        foreach (var item in items)
 375        {
 376            // If the physical root changed, return the user root
 0377            if (item is AggregateFolder)
 378            {
 379                continue;
 380            }
 381
 0382            foreach (var folder in allUserRootChildren)
 383            {
 0384                list.Add(folder.Id.ToString("N", CultureInfo.InvariantCulture));
 385            }
 386        }
 387
 0388        return list.Distinct(StringComparer.Ordinal);
 389    }
 390
 391    private T[] TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false)
 392        where T : BaseItem
 393    {
 394        // If the physical root changed, return the user root
 0395        if (item is AggregateFolder)
 396        {
 0397            return _libraryManager.GetUserRootFolder() is T t ? new[] { t } : Array.Empty<T>();
 398        }
 399
 400        // Return it only if it's in the user's library
 0401        if (includeIfNotFound || item.IsVisibleStandalone(user))
 402        {
 0403            return new[] { item };
 404        }
 405
 0406        return Array.Empty<T>();
 407    }
 408
 409    /// <inheritdoc />
 410    public void Dispose()
 411    {
 29412        _libraryUpdateTimer?.Dispose();
 29413        _libraryUpdateTimer = null;
 29414    }
 415}

Methods/Properties

.ctor(MediaBrowser.Controller.Library.ILibraryManager,MediaBrowser.Controller.Configuration.IServerConfigurationManager,MediaBrowser.Controller.Session.ISessionManager,MediaBrowser.Controller.Library.IUserManager,Microsoft.Extensions.Logging.ILogger`1<Emby.Server.Implementations.EntryPoints.LibraryChangedNotifier>,MediaBrowser.Controller.Providers.IProviderManager)
StartAsync(System.Threading.CancellationToken)
StopAsync(System.Threading.CancellationToken)
OnProviderRefreshProgress(System.Object,Jellyfin.Data.Events.GenericEventArgs`1<System.Tuple`2<MediaBrowser.Controller.Entities.BaseItem,System.Double>>)
OnProviderRefreshStarted(System.Object,Jellyfin.Data.Events.GenericEventArgs`1<MediaBrowser.Controller.Entities.BaseItem>)
OnProviderRefreshCompleted(System.Object,Jellyfin.Data.Events.GenericEventArgs`1<MediaBrowser.Controller.Entities.BaseItem>)
EnableRefreshMessage(MediaBrowser.Controller.Entities.BaseItem)
OnLibraryItemAdded(System.Object,MediaBrowser.Controller.Library.ItemChangeEventArgs)
OnLibraryItemUpdated(System.Object,MediaBrowser.Controller.Library.ItemChangeEventArgs)
OnLibraryItemRemoved(System.Object,MediaBrowser.Controller.Library.ItemChangeEventArgs)
OnLibraryChange(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Controller.Entities.BaseItem,System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Controller.Entities.BaseItem>,System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Controller.Entities.Folder>)
LibraryUpdateTimerCallback()
SendChangeNotifications()
GetLibraryUpdateInfo(System.Collections.Generic.List`1<MediaBrowser.Controller.Entities.BaseItem>,System.Collections.Generic.List`1<MediaBrowser.Controller.Entities.BaseItem>,System.Collections.Generic.List`1<MediaBrowser.Controller.Entities.BaseItem>,System.Collections.Generic.List`1<MediaBrowser.Controller.Entities.Folder>,System.Collections.Generic.List`1<MediaBrowser.Controller.Entities.Folder>,System.Guid)
FilterItem(MediaBrowser.Controller.Entities.BaseItem)
GetTopParentIds(System.Collections.Generic.List`1<MediaBrowser.Controller.Entities.BaseItem>,System.Collections.Generic.List`1<MediaBrowser.Controller.Entities.Folder>)
TranslatePhysicalItemToUserLibrary(T,Jellyfin.Database.Implementations.Entities.User,System.Boolean)
Dispose()