aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs224
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs515
2 files changed, 739 insertions, 0 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
new file mode 100644
index 0000000000..717085b440
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
@@ -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));
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
new file mode 100644
index 0000000000..fae2dd0628
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
@@ -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));
+}