using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Events; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints; /// <summary> /// A <see cref="IHostedService"/> responsible for notifying users when libraries are updated. /// </summary> public sealed class LibraryChangedNotifier : IHostedService, IDisposable { private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IProviderManager _providerManager; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; private readonly ILogger<LibraryChangedNotifier> _logger; private readonly object _libraryChangedSyncLock = new(); private readonly List<Folder> _foldersAddedTo = new(); private readonly List<Folder> _foldersRemovedFrom = new(); private readonly List<BaseItem> _itemsAdded = new(); private readonly List<BaseItem> _itemsRemoved = new(); private readonly List<BaseItem> _itemsUpdated = new(); private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new(); private Timer? _libraryUpdateTimer; /// <summary> /// Initializes a new instance of the <see cref="LibraryChangedNotifier"/> class. /// </summary> /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> /// <param name="configurationManager">The <see cref="IServerConfigurationManager"/>.</param> /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param> /// <param name="userManager">The <see cref="IUserManager"/>.</param> /// <param name="logger">The <see cref="ILogger"/>.</param> /// <param name="providerManager">The <see cref="IProviderManager"/>.</param> public LibraryChangedNotifier( ILibraryManager libraryManager, IServerConfigurationManager configurationManager, ISessionManager sessionManager, IUserManager userManager, ILogger<LibraryChangedNotifier> logger, IProviderManager providerManager) { _libraryManager = libraryManager; _configurationManager = configurationManager; _sessionManager = sessionManager; _userManager = userManager; _logger = logger; _providerManager = providerManager; } /// <inheritdoc /> public Task StartAsync(CancellationToken cancellationToken) { _libraryManager.ItemAdded += OnLibraryItemAdded; _libraryManager.ItemUpdated += OnLibraryItemUpdated; _libraryManager.ItemRemoved += OnLibraryItemRemoved; _providerManager.RefreshCompleted += OnProviderRefreshCompleted; _providerManager.RefreshStarted += OnProviderRefreshStarted; _providerManager.RefreshProgress += OnProviderRefreshProgress; return Task.CompletedTask; } /// <inheritdoc /> public Task StopAsync(CancellationToken cancellationToken) { _libraryManager.ItemAdded -= OnLibraryItemAdded; _libraryManager.ItemUpdated -= OnLibraryItemUpdated; _libraryManager.ItemRemoved -= OnLibraryItemRemoved; _providerManager.RefreshCompleted -= OnProviderRefreshCompleted; _providerManager.RefreshStarted -= OnProviderRefreshStarted; _providerManager.RefreshProgress -= OnProviderRefreshProgress; return Task.CompletedTask; } private void OnProviderRefreshProgress(object? sender, GenericEventArgs<Tuple<BaseItem, double>> e) { var item = e.Argument.Item1; if (!EnableRefreshMessage(item)) { return; } var progress = e.Argument.Item2; if (_lastProgressMessageTimes.TryGetValue(item.Id, out var lastMessageSendTime)) { if (progress > 0 && progress < 100 && (DateTime.UtcNow - lastMessageSendTime).TotalMilliseconds < 1000) { return; } } _lastProgressMessageTimes.AddOrUpdate(item.Id, _ => DateTime.UtcNow, (_, _) => DateTime.UtcNow); var dict = new Dictionary<string, string>(); dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture); dict["Progress"] = progress.ToString(CultureInfo.InvariantCulture); try { _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, dict, CancellationToken.None); } catch { } var collectionFolders = _libraryManager.GetCollectionFolders(item); foreach (var collectionFolder in collectionFolders) { var collectionFolderDict = new Dictionary<string, string> { ["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture), ["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture) }; try { _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, collectionFolderDict, CancellationToken.None); } catch { } } } private void OnProviderRefreshStarted(object? sender, GenericEventArgs<BaseItem> e) => OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0))); private void OnProviderRefreshCompleted(object? sender, GenericEventArgs<BaseItem> e) { OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100))); _lastProgressMessageTimes.TryRemove(e.Argument.Id, out _); } private static bool EnableRefreshMessage(BaseItem item) => item is Folder { IsRoot: false, IsTopParent: true } and not (AggregateFolder or UserRootFolder or UserView or Channel); private void OnLibraryItemAdded(object? sender, ItemChangeEventArgs e) => OnLibraryChange(e.Item, e.Parent, _itemsAdded, _foldersAddedTo); private void OnLibraryItemUpdated(object? sender, ItemChangeEventArgs e) => OnLibraryChange(e.Item, e.Parent, _itemsUpdated, null); private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e) => OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom); private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList) { if (!FilterItem(item)) { return; } lock (_libraryChangedSyncLock) { var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); if (_libraryUpdateTimer is null) { _libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan); } else { _libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan); } if (foldersList is not null && parent is Folder folder) { foldersList.Add(folder); } itemsList.Add(item); } } private async void LibraryUpdateTimerCallback(object? state) { List<Folder> foldersAddedTo; List<Folder> foldersRemovedFrom; List<BaseItem> itemsUpdated; List<BaseItem> itemsAdded; List<BaseItem> itemsRemoved; lock (_libraryChangedSyncLock) { // Remove dupes in case some were saved multiple times foldersAddedTo = _foldersAddedTo .DistinctBy(x => x.Id) .ToList(); foldersRemovedFrom = _foldersRemovedFrom .DistinctBy(x => x.Id) .ToList(); itemsUpdated = _itemsUpdated .Where(i => !_itemsAdded.Contains(i)) .DistinctBy(x => x.Id) .ToList(); itemsAdded = _itemsAdded.ToList(); itemsRemoved = _itemsRemoved.ToList(); if (_libraryUpdateTimer is not null) { _libraryUpdateTimer.Dispose(); _libraryUpdateTimer = null; } _itemsAdded.Clear(); _itemsRemoved.Clear(); _itemsUpdated.Clear(); _foldersAddedTo.Clear(); _foldersRemovedFrom.Clear(); } await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false); } private async Task SendChangeNotifications( List<BaseItem> itemsAdded, List<BaseItem> itemsUpdated, List<BaseItem> itemsRemoved, List<Folder> foldersAddedTo, List<Folder> foldersRemovedFrom, CancellationToken cancellationToken) { var userIds = _sessionManager.Sessions .Select(i => i.UserId) .Where(i => !i.IsEmpty()) .Distinct() .ToArray(); foreach (var userId in userIds) { LibraryUpdateInfo info; try { info = GetLibraryUpdateInfo(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, userId); } catch (Exception ex) { _logger.LogError(ex, "Error in GetLibraryUpdateInfo"); return; } if (info.IsEmpty) { continue; } try { await _sessionManager.SendMessageToUserSessions( new List<Guid> { userId }, SessionMessageType.LibraryChanged, info, cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error sending LibraryChanged message"); } } } private LibraryUpdateInfo GetLibraryUpdateInfo( List<BaseItem> itemsAdded, List<BaseItem> itemsUpdated, List<BaseItem> itemsRemoved, List<Folder> foldersAddedTo, List<Folder> foldersRemovedFrom, Guid userId) { var user = _userManager.GetUserById(userId); ArgumentNullException.ThrowIfNull(user); var newAndRemoved = new List<BaseItem>(); newAndRemoved.AddRange(foldersAddedTo); newAndRemoved.AddRange(foldersRemovedFrom); var allUserRootChildren = _libraryManager.GetUserRootFolder() .GetChildren(user, true) .OfType<Folder>() .ToList(); return new LibraryUpdateInfo { ItemsAdded = itemsAdded.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .Distinct() .ToArray(), ItemsUpdated = itemsUpdated.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .Distinct() .ToArray(), ItemsRemoved = itemsRemoved.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user, true)) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .Distinct() .ToArray(), FoldersAddedTo = foldersAddedTo.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .Distinct() .ToArray(), FoldersRemovedFrom = foldersRemovedFrom.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .Distinct() .ToArray(), CollectionFolders = GetTopParentIds(newAndRemoved, allUserRootChildren).ToArray() }; } private static bool FilterItem(BaseItem item) { if (!item.IsFolder && !item.HasPathProtocol) { return false; } if (item is IItemByName && item is not MusicArtist) { return false; } return item.SourceType == SourceType.Library; } private static IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren) { var list = new List<string>(); foreach (var item in items) { // If the physical root changed, return the user root if (item is AggregateFolder) { continue; } foreach (var folder in allUserRootChildren) { list.Add(folder.Id.ToString("N", CultureInfo.InvariantCulture)); } } return list.Distinct(StringComparer.Ordinal); } private T[] TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false) where T : BaseItem { // If the physical root changed, return the user root if (item is AggregateFolder) { return _libraryManager.GetUserRootFolder() is T t ? new[] { t } : Array.Empty<T>(); } // Return it only if it's in the user's library if (includeIfNotFound || item.IsVisibleStandalone(user)) { return new[] { item }; } return Array.Empty<T>(); } /// <inheritdoc /> public void Dispose() { _libraryUpdateTimer?.Dispose(); _libraryUpdateTimer = null; } }