From d8bbb4dfe8e614dd8754d83c622a4964af1d21f6 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 11 Apr 2026 15:44:52 +0200 Subject: [PATCH] Fix filters --- .../Library/LibraryManager.cs | 6 + Jellyfin.Api/Controllers/FilterController.cs | 8 +- Jellyfin.Api/Controllers/ItemsController.cs | 12 ++ .../Item/BaseItemRepository.TranslateQuery.cs | 120 ++++++++++++------ 4 files changed, 103 insertions(+), 43 deletions(-) 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 {