diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index c0476b00e7..eda45ce01a 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -3326,20 +3326,11 @@ public sealed class BaseItemRepository else if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.BoxSet) { var boxSetIds = baseQuery.Select(e => e.Id).ToList(); - var userId = filter.User!.Id; - var playedBoxSetIds = new List(boxSetIds.Count); - foreach (var boxSetId in boxSetIds) - { - var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, boxSetId); - var leafItems = context.BaseItems - .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem); - - if (leafItems.Any() - && leafItems.All(f => f.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - { - playedBoxSetIds.Add(boxSetId); - } - } + var playedCounts = GetPlayedAndTotalCountBatch(boxSetIds, filter.User!); + var playedBoxSetIds = playedCounts + .Where(kvp => kvp.Value.Total > 0 && kvp.Value.Played == kvp.Value.Total) + .Select(kvp => kvp.Key) + .ToList(); if (filter.IsPlayed.Value) { @@ -4113,33 +4104,66 @@ public sealed class BaseItemRepository using var dbContext = _dbProvider.CreateDbContext(); var folderIdsArray = folderIds.ToArray(); - - // Build access filter from user preferences (parental ratings, blocked/allowed tags, etc.) var filter = new InternalItemsQuery(user); + var userId = user.Id; - // Get all non-folder, non-virtual descendants via AncestorIds table - var baseQuery = dbContext.BaseItems - .Where(b => dbContext.AncestorIds - .Any(a => folderIdsArray.Contains(a.ParentItemId) && a.ItemId == b.Id)) + // Access-filtered leaf items (non-folder, non-virtual) + var leafItems = dbContext.BaseItems .Where(b => !b.IsFolder && !b.IsVirtualItem); + leafItems = ApplyAccessFiltering(dbContext, leafItems, filter); - // Apply the same access filtering as per-item path - baseQuery = ApplyAccessFiltering(dbContext, baseQuery, filter); + // Pre-compute played status to avoid repeating the subquery in each path + var playedLeafItems = leafItems + .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); - // Join back with AncestorIds to group by parent folder ID and compute counts - var results = dbContext.AncestorIds - .Where(a => folderIdsArray.Contains(a.ParentItemId)) + // Descendants via AncestorIds (regular folders: Series → Episodes, etc.) + var ancestorLeaves = dbContext.AncestorIds + .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) .Join( - baseQuery, + playedLeafItems, a => a.ItemId, b => b.Id, - (a, b) => new { a.ParentItemId, b.Id, b.UserData }) - .GroupBy(x => x.ParentItemId) + (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); + + // Direct non-folder linked children (BoxSets → Movies, etc.) + var linkedLeaves = dbContext.LinkedChildren + .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) + .Join( + playedLeafItems, + lc => lc.ChildId, + b => b.Id, + (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); + + // Linked folder children's descendants (BoxSets → Series → Episodes) + var linkedFolderLeaves = dbContext.LinkedChildren + .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) + .Join( + dbContext.BaseItems.Where(b => b.IsFolder), + lc => lc.ChildId, + b => b.Id, + (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) + .Join( + dbContext.AncestorIds, + x => x.FolderChildId, + a => a.ParentItemId, + (x, a) => new { x.ParentId, DescendantId = a.ItemId }) + .Join( + playedLeafItems, + x => x.DescendantId, + b => b.Id, + (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); + + // Union all paths and aggregate per folder + // Distinct counts ensure items reachable through multiple paths are counted once + var results = ancestorLeaves + .Union(linkedLeaves) + .Union(linkedFolderLeaves) + .GroupBy(x => x.FolderId) .Select(g => new { FolderId = g.Key, - Total = g.Count(), - Played = g.Count(x => x.UserData!.Any(ud => ud.UserId == user.Id && ud.Played)) + Total = g.Select(x => x.Id).Distinct().Count(), + Played = g.Where(x => x.Played).Select(x => x.Id).Distinct().Count() }) .ToDictionary(x => x.FolderId, x => (x.Played, x.Total)); diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 525a4bc334..7c66dc6e36 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -880,14 +880,12 @@ namespace MediaBrowser.Controller.Entities var user = query.User; - Func filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - IEnumerable items; int totalItemCount = 0; if (query.User is null) { - items = Children.Where(filter); + items = UserViewBuilder.Filter(Children, user, query, UserDataManager, LibraryManager); totalItemCount = items.Count(); } else @@ -902,7 +900,12 @@ namespace MediaBrowser.Controller.Entities NameLessThan = query.NameLessThan }; - items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); + items = UserViewBuilder.Filter( + GetChildren(user, true, out totalItemCount, childQuery), + user, + query, + UserDataManager, + LibraryManager); } return PostFilterAndSort(items, query); @@ -1337,8 +1340,7 @@ namespace MediaBrowser.Controller.Entities .Where(e => e.IsVisible(user)) .ToArray(); - var realChildren = visibleChildren - .Where(e => query is null || UserViewBuilder.FilterItem(e, query)) + var realChildren = UserViewBuilder.Filter(visibleChildren, query.User, query, UserDataManager, LibraryManager) .ToArray(); var childCount = realChildren.Length; @@ -1722,15 +1724,14 @@ namespace MediaBrowser.Controller.Entities int playedCount; int totalCount; - if (precomputedCounts.HasValue && LinkedChildren.Length == 0) + if (precomputedCounts.HasValue) { // Use batch-fetched counts (avoids N+1 queries) (playedCount, totalCount) = precomputedCounts.Value; } else { - // Fall back to per-item query for LinkedChildren items (BoxSets, Playlists) - // or when no batch data is available + // Fall back to per-item query when no batch data is available var query = new InternalItemsQuery(user); if (LinkedChildren.Length > 0) diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index b972ebaa6b..e1927a5077 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -175,9 +175,7 @@ namespace MediaBrowser.Controller.Entities.TV var user = query.User; - Func filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - - var items = GetEpisodes(user, query.DtoOptions, true).Where(filter); + var items = UserViewBuilder.Filter(GetEpisodes(user, query.DtoOptions, true), user, query, UserDataManager, LibraryManager); return PostFilterAndSort(items, query); } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 81b0fe1c8c..77bdf402e4 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -414,14 +414,54 @@ namespace MediaBrowser.Controller.Entities InternalItemsQuery query) where T : BaseItem { - items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager)); + var filtered = Filter(items, query.User, query, _userDataManager, _libraryManager); - return SortAndPage(items, null, query, _libraryManager); + return SortAndPage(filtered, null, query, _libraryManager); } - public static bool FilterItem(BaseItem item, InternalItemsQuery query) + /// + /// Batch-aware filter that applies per-item checks. + /// + /// The items to filter. + /// The user for filtering context. + /// The query parameters. + /// The user data manager. + /// The library manager. + /// The filtered items. + public static IEnumerable Filter( + IEnumerable items, + User user, + InternalItemsQuery query, + IUserDataManager userDataManager, + ILibraryManager libraryManager) { - return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager); + var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager)); + + if (query.IsPlayed.HasValue && user is not null) + { + var itemList = filtered.ToList(); + var folderIds = itemList.OfType().Select(f => f.Id).ToList(); + + if (folderIds.Count > 0) + { + var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); + var isPlayedValue = query.IsPlayed.Value; + + return itemList.Where(i => + { + if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) + { + return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; + } + + return true; + }); + } + + return itemList; + } + + return filtered; } public static QueryResult SortAndPage( @@ -453,7 +493,7 @@ namespace MediaBrowser.Controller.Entities itemsArray); } - public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager) + private static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager) { if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)) { @@ -541,10 +581,15 @@ namespace MediaBrowser.Controller.Entities if (query.IsPlayed.HasValue) { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) + // Folder.IsPlayed() hits the DB per-item (N+1 queries). + // Folders are batch-filtered by the collection Filter() overload. + if (!item.IsFolder) { - return false; + userData ??= userDataManager.GetUserData(user, item); + if (item.IsPlayed(user, userData) != query.IsPlayed.Value) + { + return false; + } } }