diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-07-28 12:42:34 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-07-28 12:43:12 +0200 |
| commit | eed664b7d351ae9a22b0722b18d65dcff3fc185d (patch) | |
| tree | c7a74f40096024617667ab266a65dc5a10d3ec9b | |
| parent | 5b550517b2ca64fee0d060f537901a2bc6604a64 (diff) | |
Fix played/unplayed filter for empty Series and BoxSets
4 files changed, 34 insertions, 36 deletions
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 } /// <inheritdoc /> - public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user) + public IQueryable<Guid> GetFoldersWithUnplayedItemsQuery(JellyfinDbContext context, IQueryable<Guid> 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<Guid> 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<Guid>().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<Guid> playedBoxSetIds = hasBoxSet - ? GetFullyPlayedFolderIdsQuery( + IQueryable<Guid> 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))); } } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 9ba103cc8b..aed11e5cc3 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -461,11 +461,12 @@ namespace MediaBrowser.Controller.Entities var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); var isPlayedValue = query.IsPlayed.Value; - return itemList.Where(i => + return itemList.Where(item => { - if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) + if (item is Folder) { - return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; + var itemCount = counts.GetValueOrDefault(item.Id); + return (itemCount.Played >= itemCount.Total) == isPlayedValue; } return true; diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index 2e29cbdbba..e74cc38f7a 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -79,14 +79,14 @@ public interface IItemQueryHelpers Guid ancestorId); /// <summary> - /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played - /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs that have at least one unplayed + /// descendant for the given user. Composable into outer queries to avoid an extra DB roundtrip. /// </summary> /// <param name="context">The database context the resulting query is bound to.</param> /// <param name="folderIds">A query yielding candidate folder IDs.</param> /// <param name="user">The user for access filtering and played status.</param> - /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> - IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + /// <returns>An <see cref="IQueryable{Guid}"/> of folder IDs with unplayed descendants.</returns> + IQueryable<Guid> GetFoldersWithUnplayedItemsQuery( JellyfinDbContext context, IQueryable<Guid> folderIds, User user); |
