From 077fa89717957f871b172ca4b2dc4a178efd3bc5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 7 Mar 2026 20:12:42 +0100 Subject: Split BaseItemRepository and IItemRepository --- .../Item/BaseItemRepository.TranslateQuery.cs | 1118 ++++++++++++++++++++ 1 file changed, 1118 insertions(+) create mode 100644 Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs new file mode 100644 index 0000000000..f3c9f2adbd --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -0,0 +1,1118 @@ +#pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1309 // Use ordinal string comparison +#pragma warning disable CA1311 // Specify a culture or use an invariant version +#pragma warning disable CA1307 // Specify StringComparison for clarity +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.MatchCriteria; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; +using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; + +namespace Jellyfin.Server.Implementations.Item; + +public sealed partial class BaseItemRepository +{ + private static readonly IReadOnlyList SearchWildcardTerms = ['%', '_', '[', ']', '^']; + + private static readonly string ImdbProviderName = MetadataProvider.Imdb.ToString().ToLowerInvariant(); + private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); + private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + + /// + public IQueryable TranslateQuery( + IQueryable baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter) + { + const int HDWidth = 1200; + const int UHDWidth = 3800; + const int UHDHeight = 2100; + + var minWidth = filter.MinWidth; + var maxWidth = filter.MaxWidth; + var now = DateTime.UtcNow; + + if (filter.IsHD.HasValue || filter.Is4K.HasValue) + { + bool includeSD = false; + bool includeHD = false; + bool include4K = false; + + if (filter.IsHD.HasValue && !filter.IsHD.Value) + { + includeSD = true; + } + + if (filter.IsHD.HasValue && filter.IsHD.Value) + { + includeHD = true; + } + + if (filter.Is4K.HasValue && filter.Is4K.Value) + { + include4K = true; + } + + 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))); + } + + if (minWidth.HasValue) + { + baseQuery = baseQuery.Where(e => e.Width >= minWidth); + } + + if (filter.MinHeight.HasValue) + { + baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight); + } + + if (maxWidth.HasValue) + { + baseQuery = baseQuery.Where(e => e.Width <= maxWidth); + } + + if (filter.MaxHeight.HasValue) + { + baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight); + } + + if (filter.IsLocked.HasValue) + { + baseQuery = baseQuery.Where(e => e.IsLocked == filter.IsLocked); + } + + var tags = filter.Tags.ToList(); + var excludeTags = filter.ExcludeTags.ToList(); + + if (filter.IsMovie.HasValue) + { + var shouldIncludeAllMovieTypes = filter.IsMovie.Value + && (filter.IncludeItemTypes.Length == 0 + || filter.IncludeItemTypes.Contains(BaseItemKind.Movie) + || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)); + + if (!shouldIncludeAllMovieTypes) + { + baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value); + } + } + + if (filter.IsSeries.HasValue) + { + baseQuery = baseQuery.Where(e => e.IsSeries == filter.IsSeries); + } + + if (filter.IsSports.HasValue) + { + if (filter.IsSports.Value) + { + tags.Add("Sports"); + } + else + { + excludeTags.Add("Sports"); + } + } + + if (filter.IsNews.HasValue) + { + if (filter.IsNews.Value) + { + tags.Add("News"); + } + else + { + excludeTags.Add("News"); + } + } + + if (filter.IsKids.HasValue) + { + if (filter.IsKids.Value) + { + tags.Add("Kids"); + } + else + { + excludeTags.Add("Kids"); + } + } + + if (!string.IsNullOrEmpty(filter.SearchTerm)) + { + var cleanedSearchTerm = filter.SearchTerm.GetCleanValue(); + var originalSearchTerm = filter.SearchTerm; + if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) + { + cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%"; + var likeSearchTerm = $"%{originalSearchTerm.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); + } + else + { + var likeSearchTerm = $"%{originalSearchTerm}%"; + baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); + } + } + + if (filter.IsFolder.HasValue) + { + baseQuery = baseQuery.Where(e => e.IsFolder == filter.IsFolder); + } + + var includeTypes = filter.IncludeItemTypes; + + // Only specify excluded types if no included types are specified + if (filter.IncludeItemTypes.Length == 0) + { + var excludeTypes = filter.ExcludeItemTypes; + if (excludeTypes.Length == 1) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(excludeTypes[0], out var excludeTypeName)) + { + baseQuery = baseQuery.Where(e => e.Type != excludeTypeName); + } + } + else if (excludeTypes.Length > 1) + { + var excludeTypeName = new List(); + foreach (var excludeType in excludeTypes) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(excludeType, out var baseItemKindName)) + { + excludeTypeName.Add(baseItemKindName!); + } + } + + baseQuery = baseQuery.Where(e => !excludeTypeName.Contains(e.Type)); + } + } + else + { + string[] types = includeTypes.Select(f => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(f)).Where(e => e != null).ToArray()!; + baseQuery = baseQuery.WhereOneOrMany(types, f => f.Type); + } + + if (filter.ChannelIds.Count > 0) + { + baseQuery = baseQuery.Where(e => e.ChannelId != null && filter.ChannelIds.Contains(e.ChannelId.Value)); + } + + if (!filter.ParentId.IsEmpty()) + { + baseQuery = baseQuery.Where(e => e.ParentId!.Value == filter.ParentId); + } + + if (!string.IsNullOrWhiteSpace(filter.Path)) + { + var pathToQuery = GetPathToSave(filter.Path); + baseQuery = baseQuery.Where(e => e.Path == pathToQuery); + } + + if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey)) + { + baseQuery = baseQuery.Where(e => e.PresentationUniqueKey == filter.PresentationUniqueKey); + } + + if (filter.MinCommunityRating.HasValue) + { + baseQuery = baseQuery.Where(e => e.CommunityRating >= filter.MinCommunityRating); + } + + if (filter.MinIndexNumber.HasValue) + { + baseQuery = baseQuery.Where(e => e.IndexNumber >= filter.MinIndexNumber); + } + + if (filter.MinParentAndIndexNumber.HasValue) + { + baseQuery = baseQuery + .Where(e => (e.ParentIndexNumber == filter.MinParentAndIndexNumber.Value.ParentIndexNumber && e.IndexNumber >= filter.MinParentAndIndexNumber.Value.IndexNumber) || e.ParentIndexNumber > filter.MinParentAndIndexNumber.Value.ParentIndexNumber); + } + + if (filter.MinDateCreated.HasValue) + { + baseQuery = baseQuery.Where(e => e.DateCreated >= filter.MinDateCreated); + } + + if (filter.MinDateLastSaved.HasValue) + { + baseQuery = baseQuery.Where(e => e.DateLastSaved != null && e.DateLastSaved >= filter.MinDateLastSaved.Value); + } + + if (filter.MinDateLastSavedForUser.HasValue) + { + baseQuery = baseQuery.Where(e => e.DateLastSaved != null && e.DateLastSaved >= filter.MinDateLastSavedForUser.Value); + } + + if (filter.IndexNumber.HasValue) + { + baseQuery = baseQuery.Where(e => e.IndexNumber == filter.IndexNumber.Value); + } + + if (filter.ParentIndexNumber.HasValue) + { + baseQuery = baseQuery.Where(e => e.ParentIndexNumber == filter.ParentIndexNumber.Value); + } + + if (filter.ParentIndexNumberNotEquals.HasValue) + { + baseQuery = baseQuery.Where(e => e.ParentIndexNumber != filter.ParentIndexNumberNotEquals.Value || e.ParentIndexNumber == null); + } + + var minEndDate = filter.MinEndDate; + var maxEndDate = filter.MaxEndDate; + + if (filter.HasAired.HasValue) + { + if (filter.HasAired.Value) + { + maxEndDate = DateTime.UtcNow; + } + else + { + minEndDate = DateTime.UtcNow; + } + } + + if (minEndDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.EndDate >= minEndDate); + } + + if (maxEndDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.EndDate <= maxEndDate); + } + + if (filter.MinStartDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.StartDate >= filter.MinStartDate.Value); + } + + if (filter.MaxStartDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.StartDate <= filter.MaxStartDate.Value); + } + + if (filter.MinPremiereDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.PremiereDate >= filter.MinPremiereDate.Value); + } + + if (filter.MaxPremiereDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.PremiereDate <= filter.MaxPremiereDate.Value); + } + + if (filter.TrailerTypes.Length > 0) + { + var trailerTypes = filter.TrailerTypes.Select(e => (int)e).ToArray(); + baseQuery = baseQuery.Where(e => e.TrailerTypes!.Any(w => trailerTypes.Contains(w.Id))); + } + + if (filter.IsAiring.HasValue) + { + if (filter.IsAiring.Value) + { + baseQuery = baseQuery.Where(e => e.StartDate <= now && e.EndDate >= now); + } + else + { + baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now); + } + } + + if (filter.PersonIds.Length > 0) + { + var peopleEntityIds = context.BaseItems + .WhereOneOrMany(filter.PersonIds, b => b.Id) + .Join( + context.Peoples, + b => b.Name, + p => p.Name, + (b, p) => p.Id); + + baseQuery = baseQuery + .Where(e => context.PeopleBaseItemMap + .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId))); + } + + if (!string.IsNullOrWhiteSpace(filter.Person)) + { + baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person)); + } + + if (!string.IsNullOrWhiteSpace(filter.MinSortName)) + { + // this does not makes sense. + // baseQuery = baseQuery.Where(e => e.SortName >= query.MinSortName); + // whereClauses.Add("SortName>=@MinSortName"); + // statement?.TryBind("@MinSortName", query.MinSortName); + } + + if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId)) + { + baseQuery = baseQuery.Where(e => e.ExternalSeriesId == filter.ExternalSeriesId); + } + + if (!string.IsNullOrWhiteSpace(filter.ExternalId)) + { + baseQuery = baseQuery.Where(e => e.ExternalId == filter.ExternalId); + } + + if (!string.IsNullOrWhiteSpace(filter.Name)) + { + if (filter.UseRawName == true) + { + baseQuery = baseQuery.Where(e => e.Name == filter.Name); + } + else + { + var cleanName = filter.Name.GetCleanValue(); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); + } + } + + // These are the same, for now + var nameContains = filter.NameContains; + if (!string.IsNullOrWhiteSpace(nameContains)) + { + if (SearchWildcardTerms.Any(f => nameContains.Contains(f))) + { + nameContains = $"%{nameContains.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName, nameContains) || EF.Functions.Like(e.OriginalTitle, nameContains)); + } + else + { + var likeNameContains = $"%{nameContains}%"; + baseQuery = baseQuery.Where(e => + e.CleanName!.Contains(nameContains) + || EF.Functions.Like(e.OriginalTitle, likeNameContains)); + } + } + + // When box set collapsing is active, defer name-range filters to after the collapse. + // Otherwise, items are filtered by their own name but then collapsed into a BoxSet + // whose name may fall in a different range (e.g. "21 Jump Street" is under "#" + // but its BoxSet "Jump Street Collection" should appear under "J"). + if (filter.CollapseBoxSetItems != true) + { + baseQuery = ApplyNameFilters(baseQuery, filter); + } + + if (filter.ImageTypes.Length > 0) + { + var imgTypes = filter.ImageTypes.Select(e => (ImageInfoImageType)e).ToArray(); + baseQuery = baseQuery.Where(e => e.Images!.Any(w => imgTypes.Contains(w.ImageType))); + } + + if (filter.IsLiked.HasValue) + { + var isLiked = filter.IsLiked.Value; + baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) == isLiked); + } + + if (filter.IsFavoriteOrLiked.HasValue) + { + var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value; + baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked); + } + + if (filter.IsFavorite.HasValue) + { + var isFavorite = filter.IsFavorite.Value; + baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite); + } + + 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) + { + // Get played series IDs by joining episodes to UserData via SeriesId (Guid foreign key). + // Don't filter episodes by TopParentIds here - the series will be filtered by baseQuery anyway. + // This allows the materialized list to be reused across library-scoped queries. + var playedSeriesIdList = context.BaseItems + .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) + .Join( + context.UserData.Where(ud => ud.UserId == filter.User!.Id && ud.Played), + episode => episode.Id, + ud => ud.ItemId, + (episode, ud) => episode.SeriesId!.Value) + .Distinct(); + + var isPlayed = filter.IsPlayed.Value; + baseQuery = baseQuery.Where(s => playedSeriesIdList.Contains(s.Id) == isPlayed); + } + 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 + .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); + } + } + + if (filter.IsResumable.HasValue) + { + var resumableItemIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0) + .Select(ud => ud.ItemId); + var isResumable = filter.IsResumable.Value; + baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable); + } + + if (filter.ArtistIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); + } + + if (filter.AlbumArtistIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.AlbumArtist, filter.AlbumArtistIds); + } + + if (filter.ContributingArtistIds.Length > 0) + { + var contributingNames = context.BaseItems + .Where(b => filter.ContributingArtistIds.Contains(b.Id)) + .Select(b => b.CleanName); + + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.Artist && + contributingNames.Contains(ivm.ItemValue.CleanValue)) + && + !e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.AlbumArtist && + contributingNames.Contains(ivm.ItemValue.CleanValue))); + } + + if (filter.AlbumIds.Length > 0) + { + var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); + baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); + } + + if (filter.ExcludeArtistIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); + } + + if (filter.GenreIds.Count > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray()); + } + + if (filter.Genres.Count > 0) + { + var cleanGenres = filter.Genres.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); + baseQuery = baseQuery + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Genre).Any(cleanGenres)); + } + + if (tags.Count > 0) + { + var cleanValues = tags.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); + baseQuery = baseQuery + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); + } + + if (excludeTags.Count > 0) + { + var cleanValues = excludeTags.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); + baseQuery = baseQuery + .Where(e => !e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); + } + + if (filter.StudioIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray()); + } + + if (filter.OfficialRatings.Length > 0) + { + var ratings = filter.OfficialRatings; + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, e => ratings.Contains(e.OfficialRating)); + } + + Expression>? minParentalRatingFilter = null; + if (filter.MinParentalRating != null) + { + var min = filter.MinParentalRating; + var minScore = min.Score; + var minSubScore = min.SubScore ?? 0; + + minParentalRatingFilter = e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue > minScore || + (e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore); + } + + Expression>? maxParentalRatingFilter = null; + if (filter.MaxParentalRating != null) + { + maxParentalRatingFilter = BuildMaxParentalRatingFilter(context, filter.MaxParentalRating); + } + + if (filter.HasParentalRating ?? false) + { + if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(minParentalRatingFilter); + } + + if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(maxParentalRatingFilter); + } + } + else if (filter.BlockUnratedItems.Length > 0) + { + var unratedItemTypes = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray(); + Expression> unratedItemFilter = e => e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType); + + if (minParentalRatingFilter != null && maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(minParentalRatingFilter.And(maxParentalRatingFilter))); + } + else if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(minParentalRatingFilter)); + } + else if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(maxParentalRatingFilter)); + } + else + { + baseQuery = baseQuery.Where(unratedItemFilter); + } + } + else if (minParentalRatingFilter != null || maxParentalRatingFilter != null) + { + if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(minParentalRatingFilter); + } + + if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(maxParentalRatingFilter); + } + } + else if (!filter.HasParentalRating ?? false) + { + baseQuery = baseQuery + .Where(e => e.InheritedParentalRatingValue == null); + } + + if (filter.HasOfficialRating.HasValue) + { + Expression> hasRating = + e => e.OfficialRating != null && e.OfficialRating != string.Empty; + + baseQuery = filter.HasOfficialRating.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasRating) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasRating); + } + + if (filter.HasOverview.HasValue) + { + if (filter.HasOverview.Value) + { + baseQuery = baseQuery + .Where(e => e.Overview != null && e.Overview != string.Empty); + } + else + { + baseQuery = baseQuery + .Where(e => e.Overview == null || e.Overview == string.Empty); + } + } + + if (filter.HasOwnerId.HasValue) + { + if (filter.HasOwnerId.Value) + { + baseQuery = baseQuery + .Where(e => e.OwnerId != null); + } + else + { + baseQuery = baseQuery + .Where(e => e.OwnerId == null); + } + } + else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) + { + // Exclude alternate versions from general queries. Alternate versions have + // OwnerId set (pointing to their primary) but no ExtraType. + // Extras (trailers, etc.) also have OwnerId but DO have ExtraType set - keep those. + baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null); + } + + if (filter.OwnerIds.Length > 0) + { + baseQuery = baseQuery.Where(e => e.OwnerId != null && filter.OwnerIds.Contains(e.OwnerId.Value)); + } + + if (filter.ExtraTypes.Length > 0) + { + // Convert ExtraType enum to BaseItemExtraType enum via int cast (same underlying values) + var extraTypeValues = filter.ExtraTypes.Select(e => (BaseItemExtraType?)(int)e).ToArray(); + baseQuery = baseQuery.Where(e => e.ExtraType != null && extraTypeValues.Contains(e.ExtraType)); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) + { + var lang = filter.HasNoAudioTrackWithLanguage; + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang)); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang)) + || (e.IsFolder && !foldersWithAudio.Contains(e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) + { + var lang = filter.HasNoInternalSubtitleTrackWithLanguage; + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false)); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) + { + var lang = filter.HasNoExternalSubtitleTrackWithLanguage; + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true)); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) + { + var lang = filter.HasNoSubtitleTrackWithLanguage; + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang)); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + + if (filter.HasSubtitles.HasValue) + { + var hasSubtitles = filter.HasSubtitles.Value; + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles()); + if (hasSubtitles) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + } + + if (filter.HasChapterImages.HasValue) + { + var hasChapterImages = filter.HasChapterImages.Value; + var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages()); + if (hasChapterImages) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null)) + || (e.IsFolder && foldersWithChapterImages.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)) + || (e.IsFolder && !foldersWithChapterImages.Contains(e.Id))); + } + } + + if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value) + { + baseQuery = baseQuery + .Where(e => e.ParentId.HasValue && !context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Any(f => f.Id == e.ParentId.Value)); + } + + if (filter.IsDeadArtist.HasValue && filter.IsDeadArtist.Value) + { + baseQuery = baseQuery + .Where(e => !context.ItemValues.Where(f => _getAllArtistsValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); + } + + if (filter.IsDeadStudio.HasValue && filter.IsDeadStudio.Value) + { + baseQuery = baseQuery + .Where(e => !context.ItemValues.Where(f => _getStudiosValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); + } + + if (filter.IsDeadGenre.HasValue && filter.IsDeadGenre.Value) + { + baseQuery = baseQuery + .Where(e => !context.ItemValues.Where(f => _getGenreValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); + } + + if (filter.IsDeadPerson.HasValue && filter.IsDeadPerson.Value) + { + baseQuery = baseQuery + .Where(e => !context.Peoples.Any(f => f.Name == e.Name)); + } + + if (filter.Years.Length > 0) + { + baseQuery = baseQuery.WhereOneOrMany(filter.Years, e => e.ProductionYear!.Value); + } + + var isVirtualItem = filter.IsVirtualItem ?? filter.IsMissing; + if (isVirtualItem.HasValue) + { + baseQuery = baseQuery + .Where(e => e.IsVirtualItem == isVirtualItem.Value); + } + + if (filter.IsSpecialSeason.HasValue) + { + if (filter.IsSpecialSeason.Value) + { + baseQuery = baseQuery + .Where(e => e.IndexNumber == 0); + } + else + { + baseQuery = baseQuery + .Where(e => e.IndexNumber != 0); + } + } + + if (filter.IsUnaired.HasValue) + { + if (filter.IsUnaired.Value) + { + baseQuery = baseQuery + .Where(e => e.PremiereDate >= now); + } + else + { + baseQuery = baseQuery + .Where(e => e.PremiereDate < now); + } + } + + if (filter.MediaTypes.Length > 0) + { + var mediaTypes = filter.MediaTypes.Select(f => f.ToString()).ToArray(); + baseQuery = baseQuery.WhereOneOrMany(mediaTypes, e => e.MediaType); + } + + if (filter.ItemIds.Length > 0) + { + baseQuery = baseQuery.WhereOneOrMany(filter.ItemIds, e => e.Id); + } + + if (filter.ExcludeItemIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => !filter.ExcludeItemIds.Contains(e.Id)); + } + + if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) + { + var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f))); + } + + if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) + { + // Allow setting a null or empty value to get all items that have the specified provider set. + var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray(); + if (includeAny.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId))); + } + + var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); + if (includeSelected.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); + } + } + + if (filter.HasImdbId.HasValue) + { + baseQuery = filter.HasImdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == ImdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != ImdbProviderName)); + } + + if (filter.HasTmdbId.HasValue) + { + baseQuery = filter.HasTmdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TmdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TmdbProviderName)); + } + + if (filter.HasTvdbId.HasValue) + { + baseQuery = filter.HasTvdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TvdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName)); + } + + var queryTopParentIds = filter.TopParentIds; + + if (queryTopParentIds.Length > 0) + { + var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); + var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; + if (enableItemsByName && includedItemByNameTypes.Count > 0) + { + baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); + } + else + { + baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); + } + } + + if (filter.AncestorIds.Length > 0) + { + var ancestorFilter = filter.AncestorIds.OneOrManyExpressionBuilder(f => f.ParentItemId); + baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); + } + + if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) + { + baseQuery = baseQuery + .Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey)) + { + baseQuery = baseQuery + .Where(e => e.SeriesPresentationUniqueKey == filter.SeriesPresentationUniqueKey); + } + + if (filter.ExcludeInheritedTags.Length > 0) + { + var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + baseQuery = baseQuery.Where(e => + !context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && excludedTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id + || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) + || e.Parents!.Any(p => f.ItemId == p.ParentItemId) + || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); + } + + if (filter.IncludeInheritedTags.Length > 0) + { + var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + baseQuery = baseQuery.Where(e => + context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && includeTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id + || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) + || e.Parents!.Any(p => f.ItemId == p.ParentItemId) + || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value))) + + // A playlist should be accessible to its owner regardless of allowed tags + || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); + } + + if (filter.SeriesStatuses.Length > 0) + { + var seriesStatus = filter.SeriesStatuses.Select(e => e.ToString()).ToArray(); + baseQuery = baseQuery + .Where(e => seriesStatus.Any(f => e.Data!.Contains(f))); + } + + if (filter.BoxSetLibraryFolders.Length > 0) + { + var boxsetFolders = filter.BoxSetLibraryFolders.Select(e => e.ToString("N", CultureInfo.InvariantCulture)).ToArray(); + baseQuery = baseQuery + .Where(e => boxsetFolders.Any(f => e.Data!.Contains(f))); + } + + if (filter.VideoTypes.Length > 0) + { + var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); + Expression> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); + } + + if (filter.Is3D.HasValue) + { + Expression> is3D = e => e.Data!.Contains("Video3DFormat"); + + baseQuery = filter.Is3D.Value + ? baseQuery.WhereItemOrDescendantMatches(context, is3D) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, is3D); + } + + if (filter.IsPlaceHolder.HasValue) + { + Expression> isPlaceHolder = e => e.Data!.Contains("IsPlaceHolder\":true"); + + baseQuery = filter.IsPlaceHolder.Value + ? baseQuery.WhereItemOrDescendantMatches(context, isPlaceHolder) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder); + } + + if (filter.HasSpecialFeature.HasValue) + { + var itemsWithExtras = context.BaseItems + .Where(extra => extra.OwnerId != null + && extra.ExtraType != null + && extra.ExtraType != BaseItemExtraType.Unknown + && extra.ExtraType != BaseItemExtraType.Trailer + && extra.ExtraType != BaseItemExtraType.ThemeSong + && extra.ExtraType != BaseItemExtraType.ThemeVideo) + .Select(extra => extra.OwnerId!.Value) + .Distinct(); + + Expression> hasExtras = e => itemsWithExtras.Contains(e.Id); + + baseQuery = filter.HasSpecialFeature.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasExtras) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasExtras); + } + + if (filter.HasTrailer.HasValue) + { + var trailerOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression> hasTrailer = e => trailerOwnerIds.Contains(e.Id); + + baseQuery = filter.HasTrailer.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasTrailer) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasTrailer); + } + + if (filter.HasThemeSong.HasValue) + { + var themeSongOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeSong.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeSong) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeSong); + } + + if (filter.HasThemeVideo.HasValue) + { + var themeVideoOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeVideo.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeVideo) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeVideo); + } + + if (filter.AiredDuringSeason.HasValue) + { + var seasonNumber = filter.AiredDuringSeason.Value; + if (seasonNumber < 1) + { + baseQuery = baseQuery.Where(e => e.ParentIndexNumber == seasonNumber); + } + else + { + var seasonStr = seasonNumber.ToString(CultureInfo.InvariantCulture); + baseQuery = baseQuery.Where(e => + e.ParentIndexNumber == seasonNumber + || (e.Data != null && ( + e.Data.Contains("\"AirsAfterSeasonNumber\":" + seasonStr) + || e.Data.Contains("\"AirsBeforeSeasonNumber\":" + seasonStr)))); + } + } + + if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty()) + { + var adjacentToId = filter.AdjacentTo.Value; + var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault(); + if (targetItem is not null) + { + var targetSortName = targetItem.SortName ?? string.Empty; + + // Fetch both prev and next adjacent items in a single query using Concat (UNION ALL). + var adjacentIds = context.BaseItems + .Where(e => string.Compare(e.SortName, targetSortName) < 0) + .OrderByDescending(e => e.SortName) + .Select(e => e.Id) + .Take(1) + .Concat( + context.BaseItems + .Where(e => string.Compare(e.SortName, targetSortName) > 0) + .OrderBy(e => e.SortName) + .Select(e => e.Id) + .Take(1)) + .ToList(); + + adjacentIds.Add(adjacentToId); + baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id)); + } + } + + return baseQuery; + } +} -- cgit v1.2.3 From 27d54c5b1c96a9b81daa772ad18207204e9ce00c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 13 Mar 2026 22:46:42 +0100 Subject: Fix IsResumable and IsPlayed filter --- .../Item/BaseItemRepository.TranslateQuery.cs | 76 +++++++++++++++++----- 1 file changed, 61 insertions(+), 15 deletions(-) (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index f3c9f2adbd..f7f48278db 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -446,20 +446,29 @@ public sealed partial class BaseItemRepository // 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) { - // Get played series IDs by joining episodes to UserData via SeriesId (Guid foreign key). - // Don't filter episodes by TopParentIds here - the series will be filtered by baseQuery anyway. - // This allows the materialized list to be reused across library-scoped queries. - var playedSeriesIdList = context.BaseItems + var userId = filter.User!.Id; + var seriesWithEpisodes = context.BaseItems .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .Join( - context.UserData.Where(ud => ud.UserId == filter.User!.Id && ud.Played), - episode => episode.Id, - ud => ud.ItemId, - (episode, ud) => episode.SeriesId!.Value) + .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; - baseQuery = baseQuery.Where(s => playedSeriesIdList.Contains(s.Id) == isPlayed); + if (isPlayed) + { + baseQuery = baseQuery.Where(s => + seriesWithEpisodes.Contains(s.Id) && !seriesWithUnplayedEpisodes.Contains(s.Id)); + } + else + { + baseQuery = baseQuery.Where(s => + !seriesWithEpisodes.Contains(s.Id) || seriesWithUnplayedEpisodes.Contains(s.Id)); + } } else if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.BoxSet) { @@ -484,11 +493,48 @@ public sealed partial class BaseItemRepository if (filter.IsResumable.HasValue) { - var resumableItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0) - .Select(ud => ud.ItemId); - var isResumable = filter.IsResumable.Value; - baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable); + if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series) + { + var userId = filter.User!.Id; + + // Series with at least one in-progress episode. + var seriesWithInProgressEpisodes = context.BaseItems + .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue + && e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)) + .Select(e => e.SeriesId!.Value) + .Distinct(); + + // Series with at least one played episode. + var seriesWithPlayedEpisodes = 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(); + + // Series with at least one unplayed episode. + 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 isResumable = filter.IsResumable.Value; + + // 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); + } + else + { + var resumableItemIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0) + .Select(ud => ud.ItemId); + var isResumable = filter.IsResumable.Value; + baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable); + } } if (filter.ArtistIds.Length > 0) -- cgit v1.2.3 From 6fdfc6a61b705191a23cf05d6648ace5607ab197 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 30 Mar 2026 19:49:36 +0200 Subject: Fix version filters --- .../Item/BaseItemRepository.QueryBuilding.cs | 6 +++--- .../Item/BaseItemRepository.TranslateQuery.cs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 22a03dafa7..812b6ab59c 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -439,11 +439,11 @@ public sealed partial class BaseItemRepository || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); } - // Exclude alternate versions from counts. Alternate versions have - // OwnerId set (pointing to their primary) but no ExtraType. + // Exclude alternate versions and owned non-extra items from counts. + // Alternate versions have PrimaryVersionId set (pointing to their primary). if (!filter.IncludeOwnedItems) { - baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null); + baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); } return baseQuery; diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index f7f48278db..c1c7e6cd95 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -721,10 +721,10 @@ public sealed partial class BaseItemRepository } else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) { - // Exclude alternate versions from general queries. Alternate versions have - // OwnerId set (pointing to their primary) but no ExtraType. - // Extras (trailers, etc.) also have OwnerId but DO have ExtraType set - keep those. - baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null); + // Exclude alternate versions and owned non-extra items from general queries. + // Alternate versions have PrimaryVersionId set (pointing to their primary). + // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. + baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); } if (filter.OwnerIds.Length > 0) -- cgit v1.2.3 From d8bbb4dfe8e614dd8754d83c622a4964af1d21f6 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 11 Apr 2026 15:44:52 +0200 Subject: Fix filters --- .../Library/LibraryManager.cs | 6 ++ Jellyfin.Api/Controllers/FilterController.cs | 8 +- Jellyfin.Api/Controllers/ItemsController.cs | 12 +++ .../Item/BaseItemRepository.TranslateQuery.cs | 118 ++++++++++++++------- 4 files changed, 102 insertions(+), 42 deletions(-) (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs') 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 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); + } - 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(); + // Non-folder items: check UserData directly + var playedItemIds = context.UserData + .Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => ud.ItemId); - var isPlayed = filter.IsPlayed.Value; 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 { -- cgit v1.2.3 From fc866a64e063c9f04df3fab9a00846501c8d2b13 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 26 Apr 2026 17:55:19 +0200 Subject: Remove unnecessary materializations --- Jellyfin.Server.Implementations/Item/BaseItemMapper.cs | 5 ----- .../Item/BaseItemRepository.ByName.cs | 18 +++++++----------- .../Item/BaseItemRepository.QueryBuilding.cs | 4 ++-- .../Item/BaseItemRepository.TranslateQuery.cs | 9 --------- Jellyfin.Server.Implementations/Item/NextUpService.cs | 10 +++------- Jellyfin.Server.Implementations/Item/OrderMapper.cs | 2 +- 6 files changed, 13 insertions(+), 35 deletions(-) (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index 831e7c3354..67a233c41d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -168,9 +168,6 @@ internal static class BaseItemMapper dto.ImageInfos = entity.Images.Select(e => MapImageFromEntity(e, appHost)).ToArray(); } - // dto.Type = entity.Type; - // dto.Data = entity.Data; - // dto.MediaType = Enum.TryParse(entity.MediaType); if (dto is IHasStartDate hasStartDate) { hasStartDate.StartDate = entity.StartDate.GetValueOrDefault(); @@ -354,8 +351,6 @@ internal static class BaseItemMapper }).ToArray() ?? []; } - // dto.Type = entity.Type; - // dto.Data = entity.Data; entity.MediaType = dto.MediaType.ToString(); if (dto is IHasStartDate hasStartDate) { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 907d8527aa..c4464008d4 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -133,8 +133,9 @@ public sealed partial class BaseItemRepository IsSeries = filter.IsSeries }); - // Materialize the matching CleanValues early. This splits one massive expression tree - // into two simpler queries, dramatically reducing EF Core expression compilation time. + // Keep this as an IQueryable sub-select. Materializing to a list would inline one + // bound parameter per CleanValue and hit SQLite's variable cap on libraries with + // high-cardinality value types (e.g. tens of thousands of artists). var matchingCleanValues = context.ItemValuesMap .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) .Join( @@ -142,8 +143,7 @@ public sealed partial class BaseItemRepository ivm => ivm.ItemId, g => g.Id, (ivm, g) => ivm.ItemValue.CleanValue) - .Distinct() - .ToList(); + .Distinct(); var innerQuery = PrepareItemQuery(context, filter) .Where(e => e.Type == returnType) @@ -170,10 +170,8 @@ public sealed partial class BaseItemRepository ExcludeItemIds = filter.ExcludeItemIds }; - // Materialize the matching IDs first. This keeps the complex nested subquery - // (inner filter + ItemValues join + search + GroupBy) as a single simple SQL statement, - // and then the entity load with Includes uses a flat WHERE Id IN (...) list. - // This avoids EF having to compile the entire nested expression tree into the final query. + // Build the master query and collapse rows that share a PresentationUniqueKey + // (e.g. alternate versions) by picking the lowest Id per group. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); var orderedMasterQuery = ApplyOrder(masterQuery, filter, context) @@ -229,9 +227,7 @@ public sealed partial class BaseItemRepository var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - - // Materialize the matching IDs to avoid nested subquery in the counts expression tree. - var itemIds = itemCountQuery.Select(e => e.Id).ToList(); + var itemIds = itemCountQuery.Select(e => e.Id); // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) // Instead, start from ItemValueMaps and join with BaseItems diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 12bb1e95d4..02664621d4 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -432,8 +432,8 @@ public sealed partial class BaseItemRepository || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); } - // Exclude alternate versions and owned non-extra items from counts. - // Alternate versions have PrimaryVersionId set (pointing to their primary). + // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. + // Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those. if (!filter.IncludeOwnedItems) { baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 664befc2ef..d14b62c3a0 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -376,14 +376,6 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person)); } - if (!string.IsNullOrWhiteSpace(filter.MinSortName)) - { - // this does not makes sense. - // baseQuery = baseQuery.Where(e => e.SortName >= query.MinSortName); - // whereClauses.Add("SortName>=@MinSortName"); - // statement?.TryBind("@MinSortName", query.MinSortName); - } - if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId)) { baseQuery = baseQuery.Where(e => e.ExternalSeriesId == filter.ExternalSeriesId); @@ -407,7 +399,6 @@ public sealed partial class BaseItemRepository } } - // These are the same, for now var nameContains = filter.NameContains; if (!string.IsNullOrWhiteSpace(nameContains)) { diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs index b25b347868..d78e246691 100644 --- a/Jellyfin.Server.Implementations/Item/NextUpService.cs +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -150,13 +150,9 @@ public class NextUpService : INextUpService .Where(id => id != Guid.Empty) .Distinct() .ToList(); - var lastWatchedEpisodes = new Dictionary(); - if (allLastWatchedIds.Count > 0) - { - var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id)); - lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter); - lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id); - } + var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id)); + lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter); + var lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id); Dictionary> specialsBySeriesKey = new(); if (includeSpecials) diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index aeea8db4d4..ada86c8b87 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -31,7 +31,7 @@ public static class OrderMapper { return (sortBy, query.User) switch { - (ItemSortBy.AirTime, _) => e => e.SortName, // TODO + (ItemSortBy.AirTime, _) => e => e.SortName, (ItemSortBy.Runtime, _) => e => e.RunTimeTicks, (ItemSortBy.Random, _) => e => EF.Functions.Random(), (ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate, -- cgit v1.2.3 From a1f3da1819ed796ab255ca14d57593cf9c6b7480 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 26 Apr 2026 18:52:22 +0200 Subject: Reduce correlated EXISTS queries --- .../Item/BaseItemRepository.QueryBuilding.cs | 130 ++++++++++++--------- .../Item/BaseItemRepository.TranslateQuery.cs | 101 +++++++--------- 2 files changed, 121 insertions(+), 110 deletions(-) (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index a1f02be059..7570421e78 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -117,28 +117,44 @@ public sealed partial class BaseItemRepository // Only collapse specific item types, keep others untouched var collapsibleTypeNames = collapsibleTypes.Select(t => _itemTypeLookup.BaseItemKindNames[t]).ToList(); + // Categorize items in currentIds in a single pass to avoid multiple correlated EXISTS over BaseItems. + var categorized = context.BaseItems + .AsNoTracking() + .Where(bi => currentIds.Contains(bi.Id)) + .Select(bi => new + { + bi.Id, + IsCollapsible = collapsibleTypeNames.Contains(bi.Type), + IsBoxSet = bi.Type == boxSetTypeName + }); + + var collapsibleChildIds = categorized.Where(c => c.IsCollapsible).Select(c => c.Id); + + // Single JOIN: manual links to BoxSet parents, restricted to currentIds children. + var manualBoxSetLinks = context.LinkedChildren + .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual + && currentIds.Contains(lc.ChildId)) + .Join( + context.BaseItems.Where(bs => bs.Type == boxSetTypeName), + lc => lc.ParentId, + bs => bs.Id, + (lc, bs) => new { lc.ChildId, lc.ParentId }); + + var childrenInBoxSet = manualBoxSetLinks.Select(x => x.ChildId).Distinct(); + // Items whose type is NOT collapsible (always kept in results) - var nonCollapsibleIds = currentIds - .Where(id => !context.BaseItems.Any(bi => bi.Id == id && collapsibleTypeNames.Contains(bi.Type))); - - // Collapsible items that are NOT in any box set (kept in results) - var collapsibleNotInBoxSet = currentIds - .Where(id => - context.BaseItems.Any(bi => bi.Id == id && collapsibleTypeNames.Contains(bi.Type)) - && !context.BaseItems.Any(bs => bs.Id == id && bs.Type == boxSetTypeName) - && !context.LinkedChildren.Any(lc => - lc.ChildId == id - && lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual - && context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName))); - - // Box set IDs containing at least one accessible collapsible child item - var boxSetIds = context.LinkedChildren - .Where(lc => - lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual - && currentIds.Contains(lc.ChildId) - && context.BaseItems.Any(bi => bi.Id == lc.ChildId && collapsibleTypeNames.Contains(bi.Type)) - && context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName)) - .Select(lc => lc.ParentId) + var nonCollapsibleIds = categorized.Where(c => !c.IsCollapsible).Select(c => c.Id); + + // Collapsible items that are not a BoxSet themselves and not a manual child of any BoxSet + var collapsibleNotInBoxSet = categorized + .Where(c => c.IsCollapsible && !c.IsBoxSet) + .Select(c => c.Id) + .Where(id => !childrenInBoxSet.Contains(id)); + + // BoxSet IDs containing at least one collapsible child item from currentIds + var boxSetIds = manualBoxSetLinks + .Where(x => collapsibleChildIds.Contains(x.ChildId)) + .Select(x => x.ParentId) .Distinct(); var collapsedIds = nonCollapsibleIds.Union(collapsibleNotInBoxSet).Union(boxSetIds); @@ -150,23 +166,25 @@ public sealed partial class BaseItemRepository IQueryable currentIds, string boxSetTypeName) { - // Items that are NOT box sets and NOT in any box set - var notInBoxSet = currentIds - .Where(id => - !context.BaseItems.Any(bs => bs.Id == id && bs.Type == boxSetTypeName) - && !context.LinkedChildren.Any(lc => - lc.ChildId == id - && lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual - && context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName))); - - // Box set IDs containing at least one accessible child item - var boxSetIds = context.LinkedChildren - .Where(lc => - lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual - && currentIds.Contains(lc.ChildId) - && context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName)) - .Select(lc => lc.ParentId) - .Distinct(); + // Single JOIN: manual links to BoxSet parents, restricted to currentIds children. + var manualBoxSetLinks = context.LinkedChildren + .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual + && currentIds.Contains(lc.ChildId)) + .Join( + context.BaseItems.Where(bs => bs.Type == boxSetTypeName), + lc => lc.ParentId, + bs => bs.Id, + (lc, bs) => new { lc.ChildId, lc.ParentId }); + + var childrenInBoxSet = manualBoxSetLinks.Select(x => x.ChildId).Distinct(); + var boxSetIds = manualBoxSetLinks.Select(x => x.ParentId).Distinct(); + + // Items in currentIds that are not BoxSets themselves and not a manual child of any BoxSet + var notInBoxSet = context.BaseItems + .AsNoTracking() + .Where(e => currentIds.Contains(e.Id) && e.Type != boxSetTypeName) + .Select(e => e.Id) + .Where(id => !childrenInBoxSet.Contains(id)); var collapsedIds = notInBoxSet.Union(boxSetIds); return context.BaseItems.AsNoTracking().Where(e => collapsedIds.Contains(e.Id)); @@ -405,32 +423,36 @@ public sealed partial class BaseItemRepository e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType)); } - // Apply excluded tags filtering (blocked tags) + // Apply excluded tags filtering (blocked tags). + // Pre-build the blocked-item-id set as a sub-select; then four index-seek Contains checks + // instead of one EXISTS over a 4-way OR predicate that defeats index seeks. if (filter.ExcludeInheritedTags.Length > 0) { var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var blockedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + baseQuery = baseQuery.Where(e => - !context.ItemValuesMap.Any(f => - f.ItemValue.Type == ItemValueType.Tags - && excludedTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id - || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) - || e.Parents!.Any(p => f.ItemId == p.ParentItemId) - || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); + !blockedTagItemIds.Contains(e.Id) + && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) + && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } - // Apply included tags filtering (allowed tags - item must have at least one) + // Apply included tags filtering (allowed tags - item must have at least one). if (filter.IncludeInheritedTags.Length > 0) { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var allowedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + baseQuery = baseQuery.Where(e => - context.ItemValuesMap.Any(f => - f.ItemValue.Type == ItemValueType.Tags - && includeTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id - || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) - || e.Parents!.Any(p => f.ItemId == p.ParentItemId) - || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); + allowedTagItemIds.Contains(e.Id) + || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) + || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))); } // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index d14b62c3a0..9a57691fbd 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -461,20 +461,14 @@ public sealed partial class BaseItemRepository var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; - // Series: played = all episodes played, unplayed = any episode unplayed - var seriesWithEpisodes = hasSeries + // 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) - .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() + .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(); // BoxSet: played = all children played @@ -496,14 +490,14 @@ public sealed partial class BaseItemRepository if (isPlayed) { baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && seriesWithEpisodes.Contains(e.Id) && !seriesWithUnplayedEpisodes.Contains(e.Id)) + (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id)) || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id)) || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id))); } else { baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && (!seriesWithEpisodes.Contains(e.Id) || seriesWithUnplayedEpisodes.Contains(e.Id))) + (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id)) || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id)) || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id))); } @@ -528,41 +522,33 @@ public sealed partial class BaseItemRepository var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; var isResumable = filter.IsResumable.Value; - // Series with at least one in-progress episode. - var seriesWithInProgressEpisodes = context.BaseItems - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue - && e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)) - .Select(e => e.SeriesId!.Value) - .Distinct(); - - // Series with at least one played episode. - var seriesWithPlayedEpisodes = 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(); - - // Series with at least one unplayed episode. - 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(); + // 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). + var resumableSeriesIds = seriesEpisodeStats + .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) + .Select(s => s.SeriesId); // 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(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)); + (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable) + || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable)); } else { @@ -1024,31 +1010,34 @@ public sealed partial class BaseItemRepository .Where(e => e.SeriesPresentationUniqueKey == filter.SeriesPresentationUniqueKey); } + // Pre-build the blocked-item-id set as a sub-select if (filter.ExcludeInheritedTags.Length > 0) { var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var blockedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + baseQuery = baseQuery.Where(e => - !context.ItemValuesMap.Any(f => - f.ItemValue.Type == ItemValueType.Tags - && excludedTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id - || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) - || e.Parents!.Any(p => f.ItemId == p.ParentItemId) - || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); + !blockedTagItemIds.Contains(e.Id) + && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) + && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } if (filter.IncludeInheritedTags.Length > 0) { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + var allowedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + baseQuery = baseQuery.Where(e => - context.ItemValuesMap.Any(f => - f.ItemValue.Type == ItemValueType.Tags - && includeTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id - || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) - || e.Parents!.Any(p => f.ItemId == p.ParentItemId) - || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value))) + allowedTagItemIds.Contains(e.Id) + || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) + || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) // A playlist should be accessible to its owner regardless of allowed tags || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); -- cgit v1.2.3 From 00b08c0b32b3c8fa36330d72e4a25c7b157de4e3 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 13:26:30 +0200 Subject: Omit BoxSet related materialization --- .../Item/BaseItemRepository.QueryBuilding.cs | 45 ++++++++-------------- .../Item/BaseItemRepository.TranslateQuery.cs | 17 ++++---- .../Persistence/IItemQueryHelpers.cs | 13 +++++++ 3 files changed, 37 insertions(+), 38 deletions(-) (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs') diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 7570421e78..d6ddf8f5c8 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -495,53 +495,49 @@ public sealed partial class BaseItemRepository && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))))); } - private Dictionary GetPlayedAndTotalCountBatch(IReadOnlyList folderIds, User user) + /// + public IQueryable GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable folderIds, User user) { + ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(folderIds); ArgumentNullException.ThrowIfNull(user); - if (folderIds.Count == 0) - { - return new Dictionary(); - } - - using var dbContext = _dbProvider.CreateDbContext(); - var folderIdsArray = folderIds.ToArray(); var filter = new InternalItemsQuery(user); var userId = user.Id; - var leafItems = dbContext.BaseItems + var leafItems = context.BaseItems + .AsNoTracking() .Where(b => !b.IsFolder && !b.IsVirtualItem); - leafItems = ApplyAccessFiltering(dbContext, leafItems, filter); + leafItems = ApplyAccessFiltering(context, leafItems, filter); var playedLeafItems = leafItems .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); - var ancestorLeaves = dbContext.AncestorIds - .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) + var ancestorLeaves = context.AncestorIds + .Where(a => folderIds.Contains(a.ParentItemId)) .Join( playedLeafItems, a => a.ItemId, b => b.Id, (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); - var linkedLeaves = dbContext.LinkedChildren - .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) + var linkedLeaves = context.LinkedChildren + .Where(lc => folderIds.Contains(lc.ParentId)) .Join( playedLeafItems, lc => lc.ChildId, b => b.Id, (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); - var linkedFolderLeaves = dbContext.LinkedChildren - .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) + var linkedFolderLeaves = context.LinkedChildren + .Where(lc => folderIds.Contains(lc.ParentId)) .Join( - dbContext.BaseItems.Where(b => b.IsFolder), + context.BaseItems.Where(b => b.IsFolder), lc => lc.ChildId, b => b.Id, (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) .Join( - dbContext.AncestorIds, + context.AncestorIds, x => x.FolderChildId, a => a.ParentItemId, (x, a) => new { x.ParentId, DescendantId = a.ItemId }) @@ -551,18 +547,11 @@ public sealed partial class BaseItemRepository b => b.Id, (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - var results = ancestorLeaves + return ancestorLeaves .Union(linkedLeaves) .Union(linkedFolderLeaves) .GroupBy(x => x.FolderId) - .Select(g => new - { - FolderId = g.Key, - Total = g.Select(x => x.Id).Distinct().Count(), - Played = g.Where(x => x.Played).Select(x => x.Id).Distinct().Count() - }) - .ToDictionary(x => x.FolderId, x => (x.Played, x.Total)); - - return results; + .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()) + .Select(g => g.Key); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 9a57691fbd..0abe981af8 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -471,16 +471,13 @@ public sealed partial class BaseItemRepository .Select(g => g.Key) : 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); - } + // BoxSet: played = all children played. + IQueryable playedBoxSetIds = hasBoxSet + ? GetFullyPlayedFolderIdsQuery( + 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 diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index 45fa92c90b..2e29cbdbba 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -78,6 +78,19 @@ public interface IItemQueryHelpers InternalItemsQuery filter, Guid ancestorId); + /// + /// Builds an of folder IDs whose descendants are all played + /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// + /// 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 fully-played folder IDs. + IQueryable GetFullyPlayedFolderIdsQuery( + JellyfinDbContext context, + IQueryable folderIds, + User user); + /// /// Deserializes a into a . /// -- cgit v1.2.3