diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-31 17:56:56 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-31 17:56:56 +0200 |
| commit | 7ccce8e0e7a9446db0789c6c6799501b5609718e (patch) | |
| tree | a5860be4081fb64b2be6cd697862f1cf548de0c1 | |
| parent | 420d44f638c44b942b43303c3165c2e8795b9020 (diff) | |
Fix recursive handling for LinkedChildren
4 files changed, 160 insertions, 12 deletions
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index c045f8558c..3db8265f6e 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1984,18 +1984,10 @@ namespace Emby.Server.Implementations.Library { // Playlists and BoxSets store their contents in LinkedChildren and never // populate AncestorIds for those items, so a recursive AncestorIds query - // would return zero rows. Resolve to the linked child IDs up front and - // route through the existing indexed ItemIds filter. - query.ItemIds = folder.LinkedChildren - .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) - .Select(lc => lc.ItemId!.Value) - .ToArray(); - - // Empty linked-children should still return empty rather than scanning everything. - if (query.ItemIds.Length == 0) - { - query.ItemIds = [Guid.NewGuid()]; - } + // would return zero rows. Filter by the descendant set instead, which follows + // the links and keeps descending, so a linked folder contributes what is below + // it as well - the episodes of a Series added to a collection, for example. + query.DescendantOfId = folder.Id; } else { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 1e30f0164e..d635b38df5 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1091,6 +1091,12 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); } + if (filter.DescendantOfId.HasValue) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value); + baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id)); + } + if (filter.LinkedChildAncestorIds.Length > 0) { // Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids. diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index e85f86b72f..0e5a5047cd 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -103,6 +103,7 @@ namespace MediaBrowser.Controller.Entities || SubtitleLanguages.Count > 0 || LinkedChildAncestorIds.Length > 0 || AncestorIds.Length > 0 + || DescendantOfId.HasValue || IsFavorite.HasValue || IsFavoriteOrLiked.HasValue || IsLiked.HasValue @@ -368,6 +369,13 @@ namespace MediaBrowser.Controller.Entities /// </summary> public Guid[] LinkedChildAncestorIds { get; set; } + /// <summary> + /// Gets or sets the id of a folder whose descendants the items must be part of. + /// Unlike <see cref="AncestorIds"/> this also follows the linked children of BoxSets and + /// Playlists, so it reaches the items below a linked folder (a Series' episodes, for example). + /// </summary> + public Guid? DescendantOfId { get; set; } + public Guid[] TopParentIds { get; set; } public CollectionType?[] PresetViews { get; set; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs new file mode 100644 index 0000000000..0ca11eb58d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a +/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a +/// linked folder are only reachable by following the link and then the ancestor chain. +/// </summary> +public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _collection = Guid.NewGuid(); + private readonly Guid _series = Guid.NewGuid(); + private readonly Guid _season = Guid.NewGuid(); + private readonly Guid _episode = Guid.NewGuid(); + + // A movie the collection links directly, so the direct-child case is covered alongside the nested one. + private readonly Guid _collectionMovie = Guid.NewGuid(); + + // In the same library but outside the collection, as the control the assertions are read against. + private readonly Guid _otherSeries = Guid.NewGuid(); + private readonly Guid _otherEpisode = Guid.NewGuid(); + + public BaseItemRepositoryDescendantFilterTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void DescendantOfId_ReachesEpisodesOfALinkedSeries() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Episode] + }); + + Assert.Equal([_episode], ids); + } + + [Fact] + public void DescendantOfId_ReturnsEveryLevelBelowTheCollection() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet(); + + Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order()); + } + + [Fact] + public void DescendantOfId_KeepsDirectlyLinkedChildren() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal([_collectionMovie], ids); + } + + [Fact] + public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() }); + + Assert.Empty(ids); + } + + private void Seed(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" }); + context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" }); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. + AddAncestors(context, _series, _library); + AddAncestors(context, _season, _series, _library); + AddAncestors(context, _episode, _season, _series, _library); + AddAncestors(context, _collectionMovie, _library); + AddAncestors(context, _otherSeries, _library); + AddAncestors(context, _otherEpisode, _otherSeries, _library); + + AddLink(context, _series, 0); + AddLink(context, _collectionMovie, 1); + + context.SaveChanges(); + } + + private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _collection, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + } +} |
