Fix recursive handling for LinkedChildren

This commit is contained in:
Shadowghost
2026-08-31 17:56:56 +02:00
parent 420d44f638
commit 7ccce8e0e7
4 changed files with 160 additions and 12 deletions
@@ -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
{
@@ -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.
@@ -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; }
@@ -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
});
}
}