aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-07-28 12:42:34 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-07-28 12:43:12 +0200
commiteed664b7d351ae9a22b0722b18d65dcff3fc185d (patch)
treec7a74f40096024617667ab266a65dc5a10d3ec9b
parent5b550517b2ca64fee0d060f537901a2bc6604a64 (diff)
Fix played/unplayed filter for empty Series and BoxSets
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs26
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs29
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs7
-rw-r--r--MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs8
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);