diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 92f3c98d3a..2bcb10e9e1 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3747,6 +3747,12 @@ namespace Emby.Server.Implementations.Library
///
public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query)
{
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
return _itemRepository.GetQueryFiltersLegacy(query);
}
}
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index 5ad127ad8c..2f53784db1 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -60,9 +60,7 @@ public class FilterController : BaseJellyfinApiController
BaseItem? item = null;
if (includeItemTypes.Length != 1
- || !(includeItemTypes[0] == BaseItemKind.BoxSet
- || includeItemTypes[0] == BaseItemKind.Playlist
- || includeItemTypes[0] == BaseItemKind.Trailer
+ || !(includeItemTypes[0] == BaseItemKind.Trailer
|| includeItemTypes[0] == BaseItemKind.Program))
{
item = _libraryManager.GetParentItem(parentId, user?.Id);
@@ -127,9 +125,7 @@ public class FilterController : BaseJellyfinApiController
BaseItem? parentItem = null;
if (includeItemTypes.Length == 1
- && (includeItemTypes[0] == BaseItemKind.BoxSet
- || includeItemTypes[0] == BaseItemKind.Playlist
- || includeItemTypes[0] == BaseItemKind.Trailer
+ && (includeItemTypes[0] == BaseItemKind.Trailer
|| includeItemTypes[0] == BaseItemKind.Program))
{
parentItem = null;
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index d2fb1cd294..97183f09d4 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -299,6 +299,18 @@ public class ItemsController : BaseJellyfinApiController
recursive = true;
includeItemTypes = new[] { BaseItemKind.Playlist };
}
+ else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
+ {
+ // When the client doesn't specify recursive/includeItemTypes, force the query
+ // through the database path where all filters (IsHD, genres, etc.) are applied.
+ recursive = true;
+ includeItemTypes = collectionType switch
+ {
+ CollectionType.boxsets => [BaseItemKind.BoxSet],
+ null => [BaseItemKind.Movie, BaseItemKind.Series], // mixed
+ _ => []
+ };
+ }
if (item is not UserRootFolder
// api keys can always access all folders
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index c1c7e6cd95..664befc2ef 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -66,10 +66,27 @@ public sealed partial class BaseItemRepository
include4K = true;
}
+ // Non-folders: check own resolution directly (no subquery).
+ // Folders (Series, BoxSets): EXISTS check on descendants/linked children.
+ // Using navigation properties (a.Item, lc.Child) produces efficient
+ // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
baseQuery = baseQuery.Where(e =>
- (includeSD && e.Width < HDWidth) ||
- (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) ||
- (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight)));
+ (!e.IsFolder && e.Width > 0
+ && ((includeSD && e.Width < HDWidth)
+ || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
+ || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
+ || (e.IsFolder
+ && (e.Children!.Any(a =>
+ a.Item.Width > 0
+ && ((includeSD && a.Item.Width < HDWidth)
+ || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
+ || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
+ || context.LinkedChildren.Any(lc =>
+ lc.ParentId == e.Id
+ && lc.Child!.Width > 0
+ && ((includeSD && lc.Child.Width < HDWidth)
+ || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
+ || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
}
if (minWidth.HasValue)
@@ -443,44 +460,63 @@ public sealed partial class BaseItemRepository
if (filter.IsPlayed.HasValue)
{
- // We should probably figure this out for all folders, but for right now, this is the only place where we need it
- if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series)
+ var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
+ var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet);
+
+ if (hasSeries || hasBoxSet)
{
var userId = filter.User!.Id;
- var seriesWithEpisodes = context.BaseItems
- .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
- .Select(e => e.SeriesId!.Value)
- .Distinct();
-
- var seriesWithUnplayedEpisodes = context.BaseItems
- .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue
- && !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
- .Select(e => e.SeriesId!.Value)
- .Distinct();
-
var isPlayed = filter.IsPlayed.Value;
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
+
+ // Series: played = all episodes played, unplayed = any episode unplayed
+ var seriesWithEpisodes = hasSeries
+ ? context.BaseItems
+ .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
+ .Select(e => e.SeriesId!.Value)
+ .Distinct()
+ : Enumerable.Empty().AsQueryable();
+
+ var seriesWithUnplayedEpisodes = hasSeries
+ ? context.BaseItems
+ .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue
+ && !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
+ .Select(e => e.SeriesId!.Value)
+ .Distinct()
+ : Enumerable.Empty().AsQueryable();
+
+ // BoxSet: played = all children played
+ IEnumerable playedBoxSetIds = [];
+ if (hasBoxSet)
+ {
+ var boxSetIds = baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id).ToList();
+ var playedCounts = GetPlayedAndTotalCountBatch(boxSetIds, filter.User!);
+ playedBoxSetIds = playedCounts
+ .Where(kvp => kvp.Value.Total > 0 && kvp.Value.Played == kvp.Value.Total)
+ .Select(kvp => kvp.Key);
+ }
+
+ // 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(s =>
- seriesWithEpisodes.Contains(s.Id) && !seriesWithUnplayedEpisodes.Contains(s.Id));
+ baseQuery = baseQuery.Where(e =>
+ (e.Type == seriesTypeName && seriesWithEpisodes.Contains(e.Id) && !seriesWithUnplayedEpisodes.Contains(e.Id))
+ || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id))
+ || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id)));
}
else
{
- baseQuery = baseQuery.Where(s =>
- !seriesWithEpisodes.Contains(s.Id) || seriesWithUnplayedEpisodes.Contains(s.Id));
+ baseQuery = baseQuery.Where(e =>
+ (e.Type == seriesTypeName && (!seriesWithEpisodes.Contains(e.Id) || seriesWithUnplayedEpisodes.Contains(e.Id)))
+ || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id))
+ || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id)));
}
}
- else if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.BoxSet)
- {
- var boxSetIds = baseQuery.Select(e => e.Id).ToList();
- 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);
-
- var isPlayedBoxSet = filter.IsPlayed.Value;
- baseQuery = baseQuery.Where(s => playedBoxSetIds.Contains(s.Id) == isPlayedBoxSet);
- }
else
{
var playedItemIds = context.UserData
@@ -493,9 +529,13 @@ public sealed partial class BaseItemRepository
if (filter.IsResumable.HasValue)
{
- if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series)
+ var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
+
+ if (hasSeries)
{
var userId = filter.User!.Id;
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ var isResumable = filter.IsResumable.Value;
// Series with at least one in-progress episode.
var seriesWithInProgressEpisodes = context.BaseItems
@@ -518,14 +558,20 @@ public sealed partial class BaseItemRepository
.Select(e => e.SeriesId!.Value)
.Distinct();
- var isResumable = filter.IsResumable.Value;
+ // Non-series items: resumable if PlaybackPositionTicks > 0
+ var resumableItemIds = context.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)
+ .Select(ud => ud.ItemId);
// A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched).
- baseQuery = baseQuery.Where(s =>
- (seriesWithInProgressEpisodes.Contains(s.Id)
- || (seriesWithPlayedEpisodes.Contains(s.Id) && seriesWithUnplayedEpisodes.Contains(s.Id)))
- == isResumable);
+ baseQuery = baseQuery.Where(e =>
+ (e.Type == seriesTypeName
+ && (seriesWithInProgressEpisodes.Contains(e.Id)
+ || (seriesWithPlayedEpisodes.Contains(e.Id) && seriesWithUnplayedEpisodes.Contains(e.Id)))
+ == isResumable)
+ || (e.Type != seriesTypeName
+ && resumableItemIds.Contains(e.Id) == isResumable));
}
else
{