diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 0044fcd4dc..97e03b45c9 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2332,7 +2332,10 @@ namespace Emby.Server.Implementations.Library { IOrderedEnumerable? orderedItems = null; - foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null)) + var comparers = sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null).ToList(); + items = PrefetchUserData(items, user, comparers); + + foreach (var orderBy in comparers) { if (orderBy is RandomComparer) { @@ -2364,14 +2367,14 @@ namespace Emby.Server.Implementations.Library { IOrderedEnumerable? orderedItems = null; - foreach (var (name, sortOrder) in orderBy) - { - var comparer = GetComparer(name, user); - if (comparer is null) - { - continue; - } + var comparers = orderBy + .Select(o => (Comparer: GetComparer(o.OrderBy, user), o.SortOrder)) + .Where(c => c.Comparer is not null) + .ToList(); + items = PrefetchUserData(items, user, comparers.Select(c => c.Comparer).ToList()); + foreach (var (comparer, sortOrder) in comparers) + { if (comparer is RandomComparer) { var randomItems = items.ToArray(); @@ -2397,6 +2400,31 @@ namespace Emby.Server.Implementations.Library return orderedItems ?? items; } + // The user comparers read user data per item, so without one batched read up front an + // in-memory sort would issue a database round trip per comparison. + private IEnumerable PrefetchUserData(IEnumerable items, User? user, IReadOnlyList comparers) + { + if (user is null) + { + return items; + } + + var userComparers = comparers.OfType().ToList(); + if (userComparers.Count == 0) + { + return items; + } + + var itemList = items as IReadOnlyList ?? items.ToList(); + var userData = _userDataManager.GetUserDataBatch(itemList, user); + foreach (var comparer in userComparers) + { + comparer.PrefetchedUserData = userData; + } + + return itemList; + } + /// /// Gets the comparer. /// diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 0680046c11..4ddbd60671 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -2,10 +2,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; -using BitFaster.Caching.Lru; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Configuration; @@ -27,7 +25,6 @@ namespace Emby.Server.Implementations.Library { private readonly IServerConfigurationManager _config; private readonly IDbContextFactory _repository; - private readonly FastConcurrentLru _cache; /// /// Initializes a new instance of the class. @@ -40,7 +37,6 @@ namespace Emby.Server.Implementations.Library { _config = config; _repository = repository; - _cache = new FastConcurrentLru(Environment.ProcessorCount, _config.Configuration.CacheSize, StringComparer.OrdinalIgnoreCase); } /// @@ -77,11 +73,6 @@ namespace Emby.Server.Implementations.Library dbContext.SaveChanges(); transaction.Commit(); - var userId = user.InternalId; - var cacheKey = GetCacheKey(userId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); - item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata - UserDataSaved?.Invoke(this, new UserDataSaveEventArgs { Keys = keys, @@ -180,64 +171,41 @@ namespace Emby.Server.Implementations.Library /// public Dictionary GetUserDataBatch(IReadOnlyList items, User user) { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(user); + var result = new Dictionary(items.Count); - var itemsNeedingQuery = new List<(BaseItem Item, List Keys)>(); - - foreach (var item in items) - { - var cacheKey = GetCacheKey(user.InternalId, item.Id); - if (_cache.TryGet(cacheKey, out var cachedData)) - { - result[item.Id] = cachedData; - } - else - { - var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); - var userData = userDataRow is not null ? Map(userDataRow) : null; - if (userData is not null) - { - result[item.Id] = userData; - _cache.AddOrUpdate(cacheKey, userData); - } - else - { - var keys = item.GetUserDataKeys(); - itemsNeedingQuery.Add((item, keys)); - } - } - } - - if (itemsNeedingQuery.Count == 0) + if (items.Count == 0) { return result; } - // Build a single query for all missing items. Fetch rows by item alone so rows kept - // under keys from older metadata resolve the same way as the in-memory path. - var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList(); + // Fetch rows by item alone so rows kept under keys from older metadata resolve the same + // way as the single item path. + var itemIds = items.Select(e => e.Id).Distinct().ToList(); using var context = _repository.CreateDbContext(); - var userDataArray = context.UserData + var userDataByItem = context.UserData .AsNoTracking() .Where(e => e.UserId.Equals(user.Id)) - .WhereOneOrMany(allItemIds, e => e.ItemId) - .ToArray(); + .WhereOneOrMany(itemIds, e => e.ItemId) + .ToArray() + .GroupBy(e => e.ItemId) + .ToDictionary(g => g.Key, g => g.ToArray()); - var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); - foreach (var (item, keys) in itemsNeedingQuery) + foreach (var item in items) { - UserItemData userData; - if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) + if (result.ContainsKey(item.Id)) { - userData = Map(ResolveUserDataRow(item, itemUserData)!); - } - else - { - userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; + continue; } - result[item.Id] = userData; - var cacheKey = GetCacheKey(user.InternalId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); + var row = userDataByItem.TryGetValue(item.Id, out var itemUserData) + ? ResolveUserDataRow(item, itemUserData) + : null; + + result[item.Id] = row is not null + ? Map(row) + : new UserItemData { Key = item.GetUserDataKeys().FirstOrDefault() ?? string.Empty }; } return result; @@ -340,20 +308,19 @@ namespace Emby.Server.Implementations.Library return result; } - /// - /// Gets the internal key. - /// - /// System.String. - private static string GetCacheKey(long internalUserId, Guid itemId) - { - return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N", CultureInfo.InvariantCulture); - } - /// public UserItemData? GetUserData(User user, BaseItem item) { ArgumentNullException.ThrowIfNull(user); - var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + ArgumentNullException.ThrowIfNull(item); + + using var dbContext = _repository.CreateDbContext(); + var rows = dbContext.UserData + .AsNoTracking() + .Where(e => e.ItemId == item.Id && e.UserId == user.Id) + .ToArray(); + + var row = ResolveUserDataRow(item, rows); return row is not null ? Map(row) : new UserItemData() { Key = item.GetUserDataKeys()[0], @@ -536,16 +503,6 @@ namespace Emby.Server.Implementations.Library } dbContext.SaveChanges(); - - var cacheKey = GetCacheKey(user.InternalId, item.Id); - if (_cache.TryGet(cacheKey, out var cached)) - { - cached.AudioStreamIndex = null; - cached.SubtitleStreamIndex = null; - _cache.AddOrUpdate(cacheKey, cached); - } - - item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); } } } diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs index 2c8e2b37d0..917485b457 100644 --- a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs @@ -1,6 +1,7 @@ #nullable disable using System; +using System.Collections.Generic; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -27,6 +28,12 @@ namespace Emby.Server.Implementations.Sorting /// The user manager. public IUserManager UserManager { get; set; } + /// + /// Gets or sets the prefetched user data. + /// + /// The prefetched user data. + public IReadOnlyDictionary PrefetchedUserData { get; set; } + /// /// Gets or sets the user data manager. /// @@ -57,7 +64,7 @@ namespace Emby.Server.Implementations.Sorting /// DateTime. private DateTime GetDate(BaseItem x) { - var userdata = UserDataManager.GetUserData(User, x); + var userdata = this.GetUserData(x); if (userdata is not null && userdata.LastPlayedDate.HasValue) { diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs index 86d08ed27b..9eafca1391 100644 --- a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs @@ -1,6 +1,8 @@ #nullable disable #pragma warning disable CS1591 +using System; +using System.Collections.Generic; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -35,6 +37,12 @@ namespace Emby.Server.Implementations.Sorting /// The user manager. public IUserManager UserManager { get; set; } + /// + /// Gets or sets the prefetched user data. + /// + /// The prefetched user data. + public IReadOnlyDictionary PrefetchedUserData { get; set; } + /// /// Compares the specified x. /// @@ -53,7 +61,7 @@ namespace Emby.Server.Implementations.Sorting /// DateTime. private int GetValue(BaseItem x) { - return x.IsFavoriteOrLiked(User, userItemData: null) ? 0 : 1; + return x.IsFavoriteOrLiked(User, this.GetUserData(x)) ? 0 : 1; } } } diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs index 9faa02f1fd..b4e3787ffc 100644 --- a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs @@ -2,6 +2,8 @@ #pragma warning disable CS1591 +using System; +using System.Collections.Generic; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -36,6 +38,12 @@ namespace Emby.Server.Implementations.Sorting /// The user manager. public IUserManager UserManager { get; set; } + /// + /// Gets or sets the prefetched user data. + /// + /// The prefetched user data. + public IReadOnlyDictionary PrefetchedUserData { get; set; } + /// /// Compares the specified x. /// @@ -54,7 +62,7 @@ namespace Emby.Server.Implementations.Sorting /// DateTime. private int GetValue(BaseItem x) { - return x.IsPlayed(User, userItemData: null) ? 0 : 1; + return x.IsPlayed(User, this.GetUserData(x)) ? 0 : 1; } } } diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs index 6f177c4637..3b27b8092a 100644 --- a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs @@ -2,6 +2,8 @@ #pragma warning disable CS1591 +using System; +using System.Collections.Generic; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -36,6 +38,12 @@ namespace Emby.Server.Implementations.Sorting /// The user manager. public IUserManager UserManager { get; set; } + /// + /// Gets or sets the prefetched user data. + /// + /// The prefetched user data. + public IReadOnlyDictionary PrefetchedUserData { get; set; } + /// /// Compares the specified x. /// @@ -54,7 +62,7 @@ namespace Emby.Server.Implementations.Sorting /// DateTime. private int GetValue(BaseItem x) { - return x.IsUnplayed(User, userItemData: null) ? 0 : 1; + return x.IsUnplayed(User, this.GetUserData(x)) ? 0 : 1; } } } diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs index 26e28b03bc..568e9e69f1 100644 --- a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs +++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs @@ -1,5 +1,7 @@ #nullable disable +using System; +using System.Collections.Generic; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -38,6 +40,12 @@ namespace Emby.Server.Implementations.Sorting /// The user manager. public IUserManager UserManager { get; set; } + /// + /// Gets or sets the prefetched user data. + /// + /// The prefetched user data. + public IReadOnlyDictionary PrefetchedUserData { get; set; } + /// /// Compares the specified x. /// @@ -56,7 +64,7 @@ namespace Emby.Server.Implementations.Sorting /// DateTime. private int GetValue(BaseItem x) { - var userdata = UserDataManager.GetUserData(User, x); + var userdata = this.GetUserData(x); return userdata is null ? 0 : userdata.PlayCount; } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 459ad1a17e..bc418b5c52 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -13,6 +13,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; @@ -124,53 +125,100 @@ namespace Emby.Server.Implementations.TV var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching); - var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(); - + var results = new List(seriesKeys.Count); foreach (var seriesKey in seriesKeys) { - if (!batchResult.TryGetValue(seriesKey, out var result)) + if (batchResult.TryGetValue(seriesKey, out var result)) { - continue; + results.Add(result); } + } - var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false); + // The selection below tests the played state of every episode it considers, so read the whole + // series batch in one query rather than one query per series. + var selectionCandidates = new List(); + foreach (var result in results) + { + AddCandidate(selectionCandidates, result.NextUp); + AddCandidate(selectionCandidates, result.LastWatched); + AddCandidate(selectionCandidates, result.NextPlayedForRewatching); + AddCandidate(selectionCandidates, result.LastWatchedForRewatching); + if (result.Specials is not null) + { + selectionCandidates.AddRange(result.Specials); + } + } + + var selectionUserData = _userDataManager.GetUserDataBatch(selectionCandidates, user); + + var candidates = new List(); + foreach (var result in results) + { + var nextEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: false, selectionUserData); if (nextEpisode is not null) { - // The last played date and the version that was actually played live on the version item's user data - // The played state propagated to the sibling versions carries no date - var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user); - nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion); - - DateTime lastWatchedDate = DateTime.MinValue; - if (result.LastWatched is not null) - { - lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); - } - - nextUpList.Add((lastWatchedDate, nextEpisode)); + candidates.Add(new NextUpCandidate(nextEpisode, result.LastWatched, !request.EnableResumable)); } if (includeRewatching) { - var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials); - + var nextPlayedEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: true, selectionUserData); if (nextPlayedEpisode is not null) { - var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user); - nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion); - - DateTime rewatchLastWatchedDate = DateTime.MinValue; - if (result.LastWatchedForRewatching is not null) - { - rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); - } - - nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode)); + // A rewatch suggestion is dropped once it has been resumed, whatever the request asked for. + candidates.Add(new NextUpCandidate(nextPlayedEpisode, result.LastWatchedForRewatching, true)); } } } + // The resume progress may live on an alternate version, so read every version in one query. + var episodeVersions = new List(); + foreach (var candidate in candidates) + { + if (candidate.DropWhenResumed) + { + candidate.EpisodeVersions = candidate.Episode.GetAllVersions(); + episodeVersions.AddRange(candidate.EpisodeVersions); + } + } + + if (episodeVersions.Count > 0) + { + var resumeUserData = _userDataManager.GetUserDataBatch(episodeVersions, user); + candidates.RemoveAll(candidate => candidate.EpisodeVersions + .Any(version => GetUserData(user, version, resumeUserData)?.PlaybackPositionTicks > 0)); + } + + // The last played date and the version that was actually played live on the version item's user data + // The played state propagated to the sibling versions carries no date + var lastWatchedVersions = new List(); + foreach (var candidate in candidates) + { + if (candidate.LastWatched is Video lastWatchedVideo) + { + candidate.LastWatchedVersions = lastWatchedVideo.GetAllVersions(); + lastWatchedVersions.AddRange(candidate.LastWatchedVersions); + } + } + + var lastWatchedUserData = _userDataManager.GetUserDataBatch(lastWatchedVersions, user); + + var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(candidates.Count); + foreach (var candidate in candidates) + { + var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(candidate.LastWatchedVersions, user, lastWatchedUserData); + var nextEpisode = GetPreferredVersion(candidate.Episode, candidate.LastWatched, playedVersion); + + DateTime lastWatchedDate = DateTime.MinValue; + if (candidate.LastWatched is not null) + { + lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); + } + + nextUpList.Add((lastWatchedDate, nextEpisode)); + } + var sortedEpisodes = nextUpList .OrderByDescending(x => x.LastWatchedDate) .Select(x => (BaseItem)x.Episode); @@ -178,12 +226,25 @@ namespace Emby.Server.Implementations.TV return GetResult(sortedEpisodes, request); } - private Episode? DetermineNextEpisode( - MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, + private static void AddCandidate(List candidates, BaseItem? item) + { + if (item is not null) + { + candidates.Add(item); + } + } + + private UserItemData? GetUserData(User user, BaseItem item, IReadOnlyDictionary prefetchedUserData) + => prefetchedUserData.TryGetValue(item.Id, out var userData) + ? userData + : _userDataManager.GetUserData(user, item); + + private Episode? SelectNextEpisode( + NextUpEpisodeBatchResult result, User user, bool includeSpecials, - bool includeResumable, - bool includePlayed) + bool includePlayed, + IReadOnlyDictionary prefetchedUserData) { var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode; var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode; @@ -217,60 +278,41 @@ namespace Emby.Server.Implementations.TV if (!includePlayed) { - sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is not { Played: true }); + sortedEpisodes = sortedEpisodes.Where(episode => GetUserData(user, episode, prefetchedUserData) is not { Played: true }); } nextEpisode = sortedEpisodes.FirstOrDefault(); } } - if (nextEpisode is not null && !includeResumable) - { - // The resume progress may live on an alternate version - foreach (var version in nextEpisode.GetAllVersions()) - { - if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0) - { - return null; - } - } - } - return nextEpisode; } - private Episode? DetermineNextEpisodeForRewatching( - MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, - User user, - bool includeSpecials) - { - return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true); - } - /// /// Gets the version of the last watched episode that was actually played, together with its last played date. /// The version that was played carries the most recent LastPlayedDate. /// dates. /// - /// The last watched episode (any version). + /// The versions of the last watched episode. /// The user. + /// User data read for every version up front. /// The played version and its last played date. - private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user) + private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion( + IReadOnlyList