From eed664b7d351ae9a22b0722b18d65dcff3fc185d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 28 Jul 2026 12:42:34 +0200 Subject: Fix played/unplayed filter for empty Series and BoxSets --- .../Item/BaseItemRepository.QueryBuilding.cs | 26 +++++++++---------- .../Item/BaseItemRepository.TranslateQuery.cs | 29 ++++++++++------------ 2 files changed, 26 insertions(+), 29 deletions(-) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index a4de9feb05..f5e2e9447a 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -503,7 +503,7 @@ public sealed partial class BaseItemRepository } /// - public IQueryable GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable folderIds, User user) + public IQueryable GetFoldersWithUnplayedItemsQuery(JellyfinDbContext context, IQueryable folderIds, User user) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(folderIds); @@ -517,24 +517,27 @@ public sealed partial class BaseItemRepository .Where(b => !b.IsFolder && !b.IsVirtualItem); leafItems = ApplyAccessFiltering(context, leafItems, filter); - var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); + // Only unplayed leaves are joined, so each branch is a semi-join per folder instead of a + // played-vs-total count per folder. Folders with no leaves at all simply never match. + var unplayedLeafItems = leafItems + .Where(b => !b.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .Select(b => new { b.Id }); var ancestorLeaves = context.AncestorIds .Where(a => folderIds.Contains(a.ParentItemId)) .Join( - playedLeafItems, + unplayedLeafItems, a => a.ItemId, b => b.Id, - (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); + (a, b) => a.ParentItemId); var linkedLeaves = context.LinkedChildren .Where(lc => folderIds.Contains(lc.ParentId)) .Join( - playedLeafItems, + unplayedLeafItems, lc => lc.ChildId, b => b.Id, - (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); + (lc, b) => lc.ParentId); var linkedFolderLeaves = context.LinkedChildren .Where(lc => folderIds.Contains(lc.ParentId)) @@ -549,16 +552,13 @@ public sealed partial class BaseItemRepository a => a.ParentItemId, (x, a) => new { x.ParentId, DescendantId = a.ItemId }) .Join( - playedLeafItems, + unplayedLeafItems, x => x.DescendantId, b => b.Id, - (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); + (x, b) => x.ParentId); return ancestorLeaves .Union(linkedLeaves) - .Union(linkedFolderLeaves) - .GroupBy(x => x.FolderId) - .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()) - .Select(g => g.Key); + .Union(linkedFolderLeaves); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 47f8a40b9c..6006bfb2bf 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -476,19 +476,14 @@ public sealed partial class BaseItemRepository var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; - // Series: played = at least one episode AND all episodes played; unplayed = otherwise. - IQueryable playedSeriesIds = hasSeries - ? context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .Select(g => g.Key) - : Enumerable.Empty().AsQueryable(); + // Series and BoxSets are matched by absence of an unplayed descendant rather than by + // "all descendants played". + var seriesEpisodes = context.BaseItems + .AsNoTracking() + .Where(e => !e.IsFolder && !e.IsVirtualItem); - // BoxSet: played = all children played. - IQueryable playedBoxSetIds = hasBoxSet - ? GetFullyPlayedFolderIdsQuery( + IQueryable unplayedBoxSetIds = hasBoxSet + ? GetFoldersWithUnplayedItemsQuery( context, baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id), filter.User!) @@ -502,15 +497,17 @@ public sealed partial class BaseItemRepository if (isPlayed) { baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id)) + (e.Type == seriesTypeName && !seriesEpisodes.Any(ep => ep.SeriesId == e.Id + && !ep.UserData!.Any(ud => ud.UserId == userId && ud.Played))) + || (e.Type == boxSetTypeName && !unplayedBoxSetIds.Contains(e.Id)) || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id))); } else { baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id)) + (e.Type == seriesTypeName && seriesEpisodes.Any(ep => ep.SeriesId == e.Id + && !ep.UserData!.Any(ud => ud.UserId == userId && ud.Played))) + || (e.Type == boxSetTypeName && unplayedBoxSetIds.Contains(e.Id)) || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id))); } } -- cgit v1.2.3 From d64e18b69a0a6089aab350f33464f362f09de942 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 29 Jul 2026 09:35:20 +0200 Subject: Fix more filter cases --- .../Extensions/ExpressionExtensions.cs | 13 +++ .../Item/BaseItemRepository.QueryBuilding.cs | 67 +++-------- .../Item/BaseItemRepository.TranslateQuery.cs | 123 ++++++--------------- .../Persistence/IItemQueryHelpers.cs | 26 +++-- 4 files changed, 85 insertions(+), 144 deletions(-) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs index d70ac672f2..0f166fc6e0 100644 --- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -39,6 +39,19 @@ public static class ExpressionExtensions return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate)); } + /// + /// Negates a predicate. + /// + /// The predicate parameter type. + /// The predicate expression to negate. + /// A new expression representing the negation of the input predicate. + public static Expression> Not(this Expression> predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return Expression.Lambda>(Expression.Not(predicate.Body), predicate.Parameters); + } + /// /// Combines two predicates into a single predicate using a logical AND operation. /// diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index f5e2e9447a..80e16ca310 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -503,62 +503,31 @@ public sealed partial class BaseItemRepository } /// - public IQueryable GetFoldersWithUnplayedItemsQuery(JellyfinDbContext context, IQueryable folderIds, User user) + public IQueryable GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(folderIds); ArgumentNullException.ThrowIfNull(user); - var filter = new InternalItemsQuery(user); - var userId = user.Id; - var leafItems = context.BaseItems .AsNoTracking() - .Where(b => !b.IsFolder && !b.IsVirtualItem); - leafItems = ApplyAccessFiltering(context, leafItems, filter); - - // Only unplayed leaves are joined, so each branch is a semi-join per folder instead of a - // played-vs-total count per folder. Folders with no leaves at all simply never match. - var unplayedLeafItems = leafItems - .Where(b => !b.UserData!.Any(ud => ud.UserId == userId && ud.Played)) - .Select(b => new { b.Id }); - - var ancestorLeaves = context.AncestorIds - .Where(a => folderIds.Contains(a.ParentItemId)) - .Join( - unplayedLeafItems, - a => a.ItemId, - b => b.Id, - (a, b) => a.ParentItemId); + .Where(e => !e.IsFolder && !e.IsVirtualItem); - var linkedLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - unplayedLeafItems, - lc => lc.ChildId, - b => b.Id, - (lc, b) => lc.ParentId); + return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems }); + } - var linkedFolderLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - context.BaseItems.Where(b => b.IsFolder), - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) - .Join( - context.AncestorIds, - x => x.FolderChildId, - a => a.ParentItemId, - (x, a) => new { x.ParentId, DescendantId = a.ItemId }) - .Join( - unplayedLeafItems, - x => x.DescendantId, - b => b.Id, - (x, b) => x.ParentId); - - return ancestorLeaves - .Union(linkedLeaves) - .Union(linkedFolderLeaves); + /// + public Expression> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable descendants) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(descendants); + + // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as + // linked children, which can themselves be folders contributing their own descendants. + // Every step is a correlated index seek, so only the rows the outer query keeps are visited + // and a folder is left as soon as its first matching descendant is found. + return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId)) + || context.LinkedChildren.Any(lc => lc.ParentId == e.Id + && (descendants.Any(d => d.Id == lc.ChildId) + || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId)))); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 6006bfb2bf..8e93d22205 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter + // instance across several lambdas, and this filter is combined into a tree more than once. + private static Expression> IsFolderFilter => e => e.IsFolder; + /// public IQueryable TranslateQuery( IQueryable baseQuery, @@ -466,94 +470,45 @@ public sealed partial class BaseItemRepository if (filter.IsPlayed.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); - var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet); + var userId = filter.User!.Id; - if (hasSeries || hasBoxSet) - { - var userId = filter.User!.Id; - var isPlayed = filter.IsPlayed.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; - - // Series and BoxSets are matched by absence of an unplayed descendant rather than by - // "all descendants played". - var seriesEpisodes = context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem); - - IQueryable unplayedBoxSetIds = hasBoxSet - ? GetFoldersWithUnplayedItemsQuery( - context, - baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id), - filter.User!) - : Enumerable.Empty().AsQueryable(); - - // Non-folder items: check UserData directly - var playedItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.Played) - .Select(ud => ud.ItemId); - - if (isPlayed) - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && !seriesEpisodes.Any(ep => ep.SeriesId == e.Id - && !ep.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - || (e.Type == boxSetTypeName && !unplayedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id))); - } - else - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && seriesEpisodes.Any(ep => ep.SeriesId == e.Id - && !ep.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - || (e.Type == boxSetTypeName && unplayedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id))); - } - } - else - { - var playedItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.Played) - .Select(ud => ud.ItemId); - var isPlayedItem = filter.IsPlayed.Value; - baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem); - } + // Leaf items carry their own played state. + var playedItemIds = context.UserData + .Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => ud.ItemId); + + // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no + // descendant is left unplayed, matching what the DTO reports for them. This has to key off + // the item itself rather than off the requested item types: tag and collection listings mix + // folders and leaf items in a single query. + var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!) + .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + + var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) + .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id))); + + baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not()); } if (filter.IsResumable.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); var userId = filter.User!.Id; var isResumable = filter.IsResumable.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; // In-progress user data rows; alternate versions track their own progress. var inProgress = context.UserData .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); - IQueryable? resumableSeriesIds = null; - if (hasSeries) - { - // Aggregate per series in a single GROUP BY pass, instead of three full scans. - var seriesEpisodeStats = context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Select(g => new - { - SeriesId = g.Key, - HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)), - HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)), - HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) - }); - - // A series is resumable if it has an in-progress episode, - // or if it has both played and unplayed episodes (partially watched). - resumableSeriesIds = seriesEpisodeStats - .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) - .Select(s => s.SeriesId); - } + // Folders are resumable when a descendant is in progress, or when they hold both played and + // unplayed descendants (partially watched). Alternate versions keep their own progress, so + // they count towards the in-progress check but not towards the played/unplayed one. + var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!); + var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)); + + var folderResumableFilter = BuildHasDescendantFilter(context, inProgressLeafItems) + .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) + .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))); if (isResumable) { @@ -561,18 +516,15 @@ public sealed partial class BaseItemRepository // Match each version on its own progress rather than coalescing onto the primary. var inProgressIds = inProgress.Select(ud => ud.ItemId); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) - : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter) + .Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id)))); // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those. - baseQuery = baseQuery.Where(e => e.Type == seriesTypeName + baseQuery = baseQuery.Where(e => e.IsFolder || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id)) || !context.BaseItems .Where(s => s.Id != e.Id @@ -591,11 +543,8 @@ public sealed partial class BaseItemRepository var resumableMovieIds = inProgress .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) - : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter.Not()) + .Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id)))); } } diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index e74cc38f7a..f9a050d591 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -79,17 +80,26 @@ public interface IItemQueryHelpers Guid ancestorId); /// - /// Builds an of folder IDs that have at least one unplayed - /// descendant for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// Builds a query for the playable leaf items a user can access. /// /// The database context the resulting query is bound to. - /// A query yielding candidate folder IDs. - /// The user for access filtering and played status. - /// An of folder IDs with unplayed descendants. - IQueryable GetFoldersWithUnplayedItemsQuery( + /// The user to filter accessible items for. + /// Whether to include alternate versions and owned items. + /// The access-filtered leaf item queryable. + IQueryable GetAccessFilteredLeafItemsQuery( JellyfinDbContext context, - IQueryable folderIds, - User user); + User user, + bool includeOwnedItems = false); + + /// + /// Builds a filter matching items that have at least one of below them. + /// + /// The database context the resulting filter is bound to. + /// A query yielding the descendants to look for. + /// A filter expression matching items with a matching descendant. + Expression> BuildHasDescendantFilter( + JellyfinDbContext context, + IQueryable descendants); /// /// Deserializes a into a . -- cgit v1.2.3