Optimize query helper memory

This commit is contained in:
Shadowghost
2026-08-11 14:17:36 +02:00
parent 6d501ba418
commit fa7fdf5884
7 changed files with 2734 additions and 125 deletions
@@ -8,7 +8,7 @@ using Jellyfin.Database.Implementations.MatchCriteria;
namespace Jellyfin.Database.Implementations;
/// <summary>
/// Provides methods for querying item hierarchies using iterative traversal.
/// Provides methods for querying item hierarchies.
/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
/// </summary>
public static class DescendantQueryHelper
@@ -32,11 +32,18 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
var descendants = TraverseHierarchyDown(context, [parentId]);
var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId);
descendants.Remove(parentId);
var hierarchyDescendants = ClosureDescendants(context, closureRoots);
return descendants.AsQueryable();
var linkedDescendants = context.LinkedChildren
.WhereOneOrMany(linkRoots, e => e.ParentId)
.Select(e => e.ChildId);
return hierarchyDescendants
.Concat(linkedDescendants)
.Where(e => !e.Equals(parentId))
.Distinct();
}
/// <summary>
@@ -51,11 +58,9 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
var descendants = TraverseHierarchyDownOwned(context, [parentId]);
descendants.Remove(parentId);
return descendants.AsQueryable();
return ClosureDescendants(context, [parentId])
.Where(e => !e.Equals(parentId))
.Distinct();
}
/// <summary>
@@ -76,11 +81,12 @@ public static class DescendantQueryHelper
return [];
}
var seedSet = new HashSet<Guid>(parentIds);
var descendants = TraverseHierarchyDownOwned(context, seedSet);
var descendants = ClosureDescendants(context, parentIds)
.Distinct()
.ToHashSet();
// Remove the seed IDs — callers want only descendants
descendants.ExceptWith(seedSet);
// The callers want only descendants, and an item is never its own descendant.
descendants.ExceptWith(parentIds);
return descendants;
}
@@ -96,28 +102,48 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(criteria);
var matchingItemIds = criteria switch
{
HasSubtitles => context.MediaStreamInfos
.Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle)
.Select(ms => ms.ItemId)
.Distinct()
.ToHashSet(),
.Select(ms => ms.ItemId),
HasChapterImages => context.Chapters
.Where(c => c.ImagePath != null)
.Select(c => c.ItemId)
.Distinct()
.ToHashSet(),
.Select(c => c.ItemId),
HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m),
_ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}")
};
var ancestors = TraverseHierarchyUp(context, matchingItemIds);
// One hop up the closure covers every ancestor level.
var hierarchyAncestors = context.AncestorIds
.Where(e => matchingItemIds.Contains(e.ItemId))
.Select(e => e.ParentItemId);
return ancestors.AsQueryable();
var linkParents = ResolveLinkParents(context, matchingItemIds, hierarchyAncestors);
// The link parents are resolved ids, so they are read back as a sub-select to keep the result
// composable. An id without a BaseItem row could never match a caller's row anyway.
var linkedParents = context.BaseItems
.WhereOneOrMany(linkParents, e => e.Id)
.Select(e => e.Id);
var linkedParentAncestors = context.AncestorIds
.WhereOneOrMany(linkParents, e => e.ItemId)
.Select(e => e.ParentItemId);
var seamAncestors = context.AncestorIds
.Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId))
.Select(e => e.ParentItemId);
return hierarchyAncestors
.Concat(linkedParents)
.Concat(linkedParentAncestors)
.Concat(seamAncestors)
.Distinct();
}
private static HashSet<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
private static IQueryable<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
{
var query = context.MediaStreamInfos
.Where(ms => ms.StreamType == criteria.StreamType
@@ -130,130 +156,131 @@ public static class DescendantQueryHelper
query = query.Where(ms => ms.IsExternal == isExternal);
}
return query.Select(ms => ms.ItemId).Distinct().ToHashSet();
return query.Select(ms => ms.ItemId);
}
private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots)
{
var direct = context.AncestorIds
.WhereOneOrMany(roots, e => e.ParentItemId)
.Select(e => e.ItemId);
// An item carries its own chain plus its collection folders, never the UserRootFolder.
var indirect = context.AncestorIds
.Where(e => direct.Contains(e.ParentItemId))
.Select(e => e.ItemId);
return direct.Concat(indirect);
}
/// <summary>
/// Traverses DOWN the hierarchy from parent folders to find all descendants.
/// Resolves every folder that reaches one of the matching items through a linked edge.
/// </summary>
private static HashSet<Guid> TraverseHierarchyDown(JellyfinDbContext context, ICollection<Guid> startIds)
/// <returns>The ids of the folders whose linked children lead, at any depth, to a matching item.</returns>
private static List<Guid> ResolveLinkParents(JellyfinDbContext context, IQueryable<Guid> matchingItemIds, IQueryable<Guid> ancestorsOfMatches)
{
var visited = new HashSet<Guid>(startIds);
var folderStack = new HashSet<Guid>(startIds);
// A link sits above the closure as well as above another link: a BoxSet holds a Series whose
// episode matches, and another BoxSet holds that BoxSet. So the hop repeats until it stops
// finding anything new, and each hop takes the links landing on the set itself or on a folder
// that contains it. Only folders owning linked children are ever collected, which bounds this
// by the number of BoxSets and Playlists rather than by the item count.
var resolved = context.LinkedChildren
.Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId))
.Select(e => e.ParentId)
.Distinct()
.ToHashSet();
while (folderStack.Count != 0)
var frontier = resolved.ToList();
while (frontier.Count != 0)
{
var currentFolders = folderStack.ToArray();
folderStack.Clear();
var containingFolders = context.AncestorIds
.WhereOneOrMany(frontier, e => e.ItemId)
.Select(e => e.ParentItemId);
var directChildren = context.AncestorIds
.WhereOneOrMany(currentFolders, e => e.ParentItemId)
.Select(e => e.ItemId)
var directLinkParents = context.LinkedChildren
.WhereOneOrMany(frontier, e => e.ChildId)
.Select(e => e.ParentId);
var indirectLinkParents = context.LinkedChildren
.Where(e => containingFolders.Contains(e.ChildId))
.Select(e => e.ParentId);
var next = directLinkParents
.Concat(indirectLinkParents)
.Distinct()
.ToArray();
var linkedChildren = context.LinkedChildren
.WhereOneOrMany(currentFolders, e => e.ParentId)
.Select(e => e.ChildId)
.ToArray();
var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray();
if (allChildren.Length == 0)
frontier = [];
foreach (var id in next)
{
break;
// Cyclic links (a BoxSet holding itself, directly or not) terminate on the resolved set.
if (resolved.Add(id))
{
frontier.Add(id);
}
}
}
var childFolders = context.BaseItems
.WhereOneOrMany(allChildren, e => e.Id)
.Where(e => e.IsFolder)
return [.. resolved];
}
/// <summary>
/// Resolves the roots the descendant sub-selects have to be anchored on.
/// </summary>
/// <returns>
/// The roots whose AncestorIds closure belongs to the result, and the roots whose LinkedChildren
/// belong to the result.
/// </returns>
private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId)
{
// A folder found through the closure needs no closure hop of its own.
var closureRoots = new List<Guid> { parentId };
var linkRoots = new List<Guid> { parentId };
var visited = new HashSet<Guid> { parentId };
var frontier = new List<Guid> { parentId };
while (frontier.Count != 0)
{
var closureIds = ClosureDescendants(context, frontier);
var linkedIds = context.LinkedChildren
.WhereOneOrMany(frontier, e => e.ParentId)
.Select(e => e.ChildId);
// Folders that own linked children, i.e. the only items whose links are worth following.
var linkOwners = context.BaseItems
.Where(e => e.IsFolder
&& (closureIds.Contains(e.Id) || linkedIds.Contains(e.Id))
&& context.LinkedChildren.Any(l => l.ParentId.Equals(e.Id)))
.Select(e => e.Id)
.ToArray();
var linkedFolders = context.BaseItems
.Where(e => e.IsFolder && linkedIds.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet();
foreach (var childId in allChildren)
frontier = [];
foreach (var id in linkOwners.Concat(linkedFolders))
{
if (visited.Add(childId) && childFolders.Contains(childId))
if (!visited.Add(id))
{
folderStack.Add(childId);
continue;
}
frontier.Add(id);
linkRoots.Add(id);
// Only a folder reached through a link contributes a closure that is not covered by
// the roots already collected.
if (linkedFolders.Contains(id))
{
closureRoots.Add(id);
}
}
}
return visited;
}
/// <summary>
/// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
/// </summary>
private static HashSet<Guid> TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection<Guid> startIds)
{
var visited = new HashSet<Guid>(startIds);
var folderStack = new HashSet<Guid>(startIds);
while (folderStack.Count != 0)
{
var currentFolders = folderStack.ToArray();
folderStack.Clear();
var directChildren = context.AncestorIds
.WhereOneOrMany(currentFolders, e => e.ParentItemId)
.Select(e => e.ItemId)
.ToArray();
if (directChildren.Length == 0)
{
break;
}
var childFolders = context.BaseItems
.WhereOneOrMany(directChildren, e => e.Id)
.Where(e => e.IsFolder)
.Select(e => e.Id)
.ToHashSet();
foreach (var childId in directChildren)
{
if (visited.Add(childId) && childFolders.Contains(childId))
{
folderStack.Add(childId);
}
}
}
return visited;
}
/// <summary>
/// Traverses UP the hierarchy from items to find all ancestor folders.
/// </summary>
private static HashSet<Guid> TraverseHierarchyUp(JellyfinDbContext context, ICollection<Guid> startIds)
{
var ancestors = new HashSet<Guid>();
var itemStack = new HashSet<Guid>(startIds);
while (itemStack.Count != 0)
{
var currentItems = itemStack.ToArray();
itemStack.Clear();
var ancestorParents = context.AncestorIds
.WhereOneOrMany(currentItems, e => e.ItemId)
.Select(e => e.ParentItemId)
.ToArray();
var linkedParents = context.LinkedChildren
.WhereOneOrMany(currentItems, e => e.ChildId)
.Select(e => e.ParentId)
.ToArray();
foreach (var parentId in ancestorParents.Concat(linkedParents))
{
if (ancestors.Add(parentId))
{
itemStack.Add(parentId);
}
}
}
return ancestors;
return (closureRoots, linkRoots);
}
}
@@ -13,5 +13,6 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration<MediaStream
public void Configure(EntityTypeBuilder<MediaStreamInfo> builder)
{
builder.HasKey(e => new { e.ItemId, e.StreamIndex });
builder.HasIndex(e => new { e.StreamType, e.ItemId });
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddMediaStreamTypeItemIdIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamType_ItemId",
table: "MediaStreamInfos",
columns: ["StreamType", "ItemId"]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_MediaStreamInfos_StreamType_ItemId",
table: "MediaStreamInfos");
}
}
}
@@ -1012,6 +1012,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("ItemId", "StreamIndex");
b.HasIndex("StreamType", "ItemId");
b.ToTable("MediaStreamInfos");
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
@@ -0,0 +1,224 @@
using System;
using System.Linq;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
namespace Jellyfin.Server.Implementations.Tests.Item;
/// <summary>
/// Covers the filters that resolve "folders with a matching descendant" through
/// <see cref="DescendantQueryHelper.GetFolderIdsMatching"/>, both in their positive and their
/// negated form, so the sub-selects they build stay translatable on the SQLite provider.
/// </summary>
public sealed class BaseItemRepositoryStreamFilterTests : IDisposable
{
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly BaseItemRepository _repository;
private readonly Guid _library = Guid.NewGuid();
private readonly Guid _withSubtitles = Guid.NewGuid();
private readonly Guid _withoutSubtitles = Guid.NewGuid();
private readonly Guid _collection = Guid.NewGuid();
private readonly Guid _linkedSeries = Guid.NewGuid();
private readonly Guid _linkedEpisode = Guid.NewGuid();
public BaseItemRepositoryStreamFilterTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
Seed(ctx);
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
_repository = new BaseItemRepository(
factory.Object,
new Mock<IServerApplicationHost>().Object,
new ItemTypeLookup(),
serverConfigurationManager.Object,
NullLogger<BaseItemRepository>.Instance);
}
public void Dispose() => _connection.Dispose();
[Fact]
public void HasSubtitles_MatchesTheItemAndItsParentFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
Assert.Contains(_withSubtitles, ids);
// The library is a folder, and it has a descendant with subtitles.
Assert.Contains(_library, ids);
Assert.DoesNotContain(_withoutSubtitles, ids);
}
[Fact]
public void HasSubtitles_Negated_ExcludesTheItemAndItsParentFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
Assert.Contains(_withoutSubtitles, ids);
Assert.DoesNotContain(_withSubtitles, ids);
Assert.DoesNotContain(_library, ids);
}
[Fact]
public void SubtitleLanguages_MatchesTheRequestedLanguageOnly()
{
Assert.Contains(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
}
[Fact]
public void HasNoSubtitleTrackWithLanguage_ExcludesTheMatchingItemAndFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
Assert.Contains(_withoutSubtitles, ids);
Assert.DoesNotContain(_withSubtitles, ids);
Assert.DoesNotContain(_library, ids);
}
[Fact]
public void HasSubtitles_MatchesACollectionLinkingAFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
// The collection links the series, and the subtitles hang off the series' episode.
Assert.Contains(_linkedSeries, ids);
Assert.Contains(_collection, ids);
}
[Fact]
public void HasSubtitles_Negated_ExcludesACollectionLinkingAFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
Assert.DoesNotContain(_linkedSeries, ids);
Assert.DoesNotContain(_collection, ids);
}
[Fact]
public void HasChapterImages_MatchesTheItemAndItsParentFolder()
{
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true });
Assert.Contains(_withSubtitles, ids);
Assert.Contains(_library, ids);
Assert.DoesNotContain(_withoutSubtitles, ids);
}
private void Seed(JellyfinDbContext context)
{
context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _withSubtitles, Type = MovieType, Name = "With subtitles" });
context.BaseItems.Add(new BaseItemEntity { Id = _withoutSubtitles, Type = MovieType, Name = "Without subtitles" });
foreach (var itemId in new[] { _withSubtitles, _withoutSubtitles })
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = _library,
Item = null!,
ParentItem = null!
});
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = itemId,
StreamIndex = 0,
StreamType = MediaStreamTypeEntity.Video,
Item = null!
});
}
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = _withSubtitles,
StreamIndex = 1,
StreamType = MediaStreamTypeEntity.Subtitle,
Language = "ger",
Item = null!
});
// A collection linking a folder: the match is two edges away, one link then one closure hop.
context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _linkedSeries, Type = FolderType, Name = "Linked series", IsFolder = true });
context.BaseItems.Add(new BaseItemEntity { Id = _linkedEpisode, Type = MovieType, Name = "Linked episode" });
context.AncestorIds.Add(new AncestorId
{
ItemId = _linkedEpisode,
ParentItemId = _linkedSeries,
Item = null!,
ParentItem = null!
});
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = _collection,
ChildId = _linkedSeries,
ChildType = LinkedChildType.Manual,
SortOrder = 0
});
context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = _linkedEpisode,
StreamIndex = 0,
StreamType = MediaStreamTypeEntity.Subtitle,
Language = "ger",
Item = null!
});
context.Chapters.Add(new Chapter
{
ItemId = _withSubtitles,
ChapterIndex = 0,
StartPositionTicks = 0,
ImagePath = "/chapter.jpg",
Item = null!
});
context.SaveChanges();
}
private JellyfinDbContext CreateDbContext()
=> new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
@@ -0,0 +1,515 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Implementations.MatchCriteria;
using Jellyfin.Database.Providers.Sqlite;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
/// <summary>
/// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that
/// they stay sub-selects instead of inlining every descendant id into the statement.
/// </summary>
public sealed class DescendantQueryHelperTests : IDisposable
{
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
private readonly Dictionary<Guid, int> _linkCounters = new();
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
public DescendantQueryHelperTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
}
public void Dispose() => _connection.Dispose();
[Fact]
public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent()
{
var library = Guid.NewGuid();
var series = Guid.NewGuid();
var season = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, library);
AddFolder(ctx, series);
AddFolder(ctx, season);
AddItem(ctx, episode, MovieType);
// AncestorIds is a closure: production writes one row per ancestor, not just the parent.
AddAncestors(ctx, series, library);
AddAncestors(ctx, season, series, library);
AddAncestors(ctx, episode, season, series, library);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
Assert.Equal(new[] { series, season, episode }.Order(), descendants.Order());
Assert.DoesNotContain(library, descendants);
}
}
[Fact]
public void GetAllDescendantIds_LinkedFolder_IncludesItsOwnDescendants()
{
var boxSet = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddAncestors(ctx, episode, series);
AddLink(ctx, boxSet, series);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet();
// The link reaches the series, and the series' own closure reaches the episode.
Assert.Contains(series, descendants);
Assert.Contains(episode, descendants);
}
}
// Timeout so that a missing termination guard fails the test instead of hanging the run.
[Fact(Timeout = 30000)]
public void GetAllDescendantIds_NestedLinks_AreFollowedAndCyclesTerminate()
{
var outer = Guid.NewGuid();
var inner = Guid.NewGuid();
var movie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, outer, BoxSetType, isFolder: true);
AddItem(ctx, inner, BoxSetType, isFolder: true);
AddItem(ctx, movie, MovieType);
AddLink(ctx, outer, inner);
AddLink(ctx, inner, movie);
// Cycle back to the outer set: the traversal must not spin on it.
AddLink(ctx, inner, outer);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, outer).ToHashSet();
Assert.Contains(inner, descendants);
Assert.Contains(movie, descendants);
Assert.DoesNotContain(outer, descendants);
}
}
[Fact]
public void GetAllDescendantIds_LinksOfNonFolders_AreNotFollowed()
{
var library = Guid.NewGuid();
var movie = Guid.NewGuid();
var alternateVersion = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, library);
AddItem(ctx, movie, MovieType);
AddItem(ctx, alternateVersion, MovieType);
AddAncestors(ctx, movie, library);
// An alternate version hangs off the movie by link, and the movie is not a folder.
AddLink(ctx, movie, alternateVersion);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
Assert.Contains(movie, descendants);
Assert.DoesNotContain(alternateVersion, descendants);
}
}
[Fact]
public void GetAllDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
{
var userRoot = Guid.NewGuid();
var collectionFolder = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
var boxSet = Guid.NewGuid();
var linkedMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, userRoot);
AddFolder(ctx, collectionFolder);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddItem(ctx, linkedMovie, MovieType);
// How production writes it: an item carries its own chain plus its collection folder, but
// not the user root above that folder - so one hop from the user root stops there.
AddAncestors(ctx, collectionFolder, userRoot);
AddAncestors(ctx, series, collectionFolder);
AddAncestors(ctx, episode, series, collectionFolder);
AddAncestors(ctx, boxSet, collectionFolder);
// The box set is only reachable across the seam, and its links have to be followed too.
AddLink(ctx, boxSet, linkedMovie);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, userRoot).ToHashSet();
Assert.Equal(
new[] { collectionFolder, series, episode, boxSet, linkedMovie }.Order(),
descendants.Order());
}
}
[Fact]
public void GetOwnedDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
{
var userRoot = Guid.NewGuid();
var collectionFolder = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
var boxSet = Guid.NewGuid();
var linkedMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, userRoot);
AddFolder(ctx, collectionFolder);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddItem(ctx, linkedMovie, MovieType);
AddAncestors(ctx, collectionFolder, userRoot);
AddAncestors(ctx, series, collectionFolder);
AddAncestors(ctx, episode, series, collectionFolder);
AddAncestors(ctx, boxSet, collectionFolder);
AddLink(ctx, boxSet, linkedMovie);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
// Owned only: the linked movie stays out, or deleting a library would delete it.
var expected = new[] { collectionFolder, series, episode, boxSet }.Order();
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIds(ctx, userRoot).ToHashSet().Order());
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [userRoot]).Order());
}
}
[Fact]
public void GetFolderIdsMatching_LinkAboveAClosure_ReturnsTheLinkingFolder()
{
var collections = Guid.NewGuid();
var boxSet = Guid.NewGuid();
var library = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
var otherLibrary = Guid.NewGuid();
var otherBoxSet = Guid.NewGuid();
var silentMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, collections);
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddFolder(ctx, library);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddAncestors(ctx, boxSet, collections);
AddAncestors(ctx, series, library);
AddAncestors(ctx, episode, series, library);
// The link lands on the series, not on the episode that carries the subtitles.
AddLink(ctx, boxSet, series);
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
// A second collection, over an item without subtitles, must not be picked up.
AddFolder(ctx, otherLibrary);
AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true);
AddItem(ctx, silentMovie, MovieType);
AddAncestors(ctx, otherBoxSet, collections);
AddAncestors(ctx, silentMovie, otherLibrary);
AddLink(ctx, otherBoxSet, silentMovie);
// A stream of another type: the criteria, not the mere presence of a stream, decides.
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
Assert.Equal(new[] { library, series, boxSet, collections }.Order(), folders.Order());
}
}
[Fact(Timeout = 30000)]
public void GetFolderIdsMatching_NestedLinks_AreFollowedAndCyclesTerminate()
{
var outer = Guid.NewGuid();
var inner = Guid.NewGuid();
var movie = Guid.NewGuid();
var silentSet = Guid.NewGuid();
var silentMovie = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, outer, BoxSetType, isFolder: true);
AddItem(ctx, inner, BoxSetType, isFolder: true);
AddItem(ctx, movie, MovieType);
AddLink(ctx, outer, inner);
AddLink(ctx, inner, movie);
// Cycle back to the outer set: resolving the link parents must not spin on it.
AddLink(ctx, inner, outer);
AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle);
AddItem(ctx, silentSet, BoxSetType, isFolder: true);
AddItem(ctx, silentMovie, MovieType);
AddLink(ctx, silentSet, silentMovie);
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
Assert.Equal(new[] { inner, outer }.Order(), folders.Order());
}
}
[Fact]
public void GetFolderIdsMatching_ClosureSeamAboveTheCollectionFolder_IsCrossed()
{
var userRoot = Guid.NewGuid();
var collectionFolder = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddFolder(ctx, userRoot);
AddFolder(ctx, collectionFolder);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
// How production writes it: an item carries its own chain plus its collection folder, but
// not the user root above that folder - so the closure is not transitive at this seam.
AddAncestors(ctx, episode, series, collectionFolder);
AddAncestors(ctx, series, collectionFolder);
AddAncestors(ctx, collectionFolder, userRoot);
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
Assert.Equal(new[] { series, collectionFolder, userRoot }.Order(), folders.Order());
}
}
[Fact]
public void GetFolderIdsMatching_LinkedFolder_MatchesOnLanguageOnly()
{
var boxSet = Guid.NewGuid();
var series = Guid.NewGuid();
var episode = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddFolder(ctx, series);
AddItem(ctx, episode, MovieType);
AddAncestors(ctx, episode, series);
AddLink(ctx, boxSet, series);
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle, "ger");
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
var german = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["ger"]);
var french = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["fre"]);
Assert.Equal(new[] { series, boxSet }.Order(), DescendantQueryHelper.GetFolderIdsMatching(ctx, german).ToHashSet().Order());
Assert.Empty(DescendantQueryHelper.GetFolderIdsMatching(ctx, french).ToArray());
}
}
[Fact]
public void GetOwnedDescendantIds_IgnoresLinkedChildren()
{
var boxSet = Guid.NewGuid();
var owned = Guid.NewGuid();
var linked = Guid.NewGuid();
using (var ctx = CreateDbContext())
{
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
AddItem(ctx, owned, MovieType);
AddItem(ctx, linked, MovieType);
AddAncestors(ctx, owned, boxSet);
AddLink(ctx, boxSet, linked);
ctx.SaveChanges();
}
using (var ctx = CreateDbContext())
{
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIds(ctx, boxSet).ToArray());
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [boxSet]).ToArray());
}
}
[Fact]
public void GetAllDescendantIds_StatementSizeDoesNotGrowWithTheLibrary()
{
var small = SeedLibrary(10);
var large = SeedLibrary(500);
using var ctx = CreateDbContext();
var smallSql = CountingQuery(ctx, small).ToQueryString();
var largeSql = CountingQuery(ctx, large).ToQueryString();
// Reading the ids into memory and passing them back as AsQueryable() makes EF inline one
// literal per descendant, which is what made a large library allocate megabytes per call.
Assert.Equal(smallSql.Length, largeSql.Length);
Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal);
Assert.Equal(10, CountingQuery(ctx, small).Count());
Assert.Equal(500, CountingQuery(ctx, large).Count());
}
private static IQueryable<BaseItemEntity> CountingQuery(JellyfinDbContext context, Guid libraryId)
{
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, libraryId);
return context.BaseItems
.AsNoTracking()
.Where(b => descendantIds.Contains(b.Id))
.Where(DescendantQueryHelper.IsCountableLeaf);
}
private Guid SeedLibrary(int childCount)
{
var library = Guid.NewGuid();
using var ctx = CreateDbContext();
AddFolder(ctx, library);
for (var i = 0; i < childCount; i++)
{
var child = Guid.NewGuid();
AddItem(ctx, child, MovieType);
AddAncestors(ctx, child, library);
}
ctx.SaveChanges();
return library;
}
private static void AddFolder(JellyfinDbContext context, Guid id)
=> AddItem(context, id, FolderType, isFolder: true);
private static void AddItem(JellyfinDbContext context, Guid id, string type, bool isFolder = false)
=> context.BaseItems.Add(new BaseItemEntity
{
Id = id,
Type = type,
Name = type + " " + id,
IsFolder = isFolder
});
private static void AddStream(JellyfinDbContext context, Guid itemId, MediaStreamTypeEntity type, string? language = null)
=> context.MediaStreamInfos.Add(new MediaStreamInfo
{
ItemId = itemId,
StreamIndex = 0,
StreamType = type,
Language = language,
Item = null!
});
private static 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!
});
}
}
// LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot.
private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId)
{
_linkCounters.TryGetValue(parentId, out var sortOrder);
_linkCounters[parentId] = sortOrder + 1;
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = LinkedChildType.Manual,
SortOrder = sortOrder
});
}
private JellyfinDbContext CreateDbContext()
=> new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}