aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs526
1 files changed, 526 insertions, 0 deletions
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..f2ecfadd50
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
@@ -0,0 +1,526 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.MatchCriteria;
+using Microsoft.EntityFrameworkCore;
+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 : SqliteDbTestFixture
+{
+ 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();
+
+ public DescendantQueryHelperTests()
+ {
+ }
+
+ [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();
+
+ 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);
+ // The traversal must not spin on this cycle.
+ 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);
+
+ // An item carries its own chain plus its collection folder, but not the user root above
+ // it, so one hop from the user root stops at the collection folder.
+ 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);
+
+ 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);
+ // Resolving the link parents must not spin on this cycle.
+ 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);
+
+ // The closure is not transitive at this seam: no item records the user root.
+ 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 GetFolderIdsMatching_AlternateVersionLinks_AreNotWalked()
+ {
+ var collections = Guid.NewGuid();
+ var boxSet = Guid.NewGuid();
+ var library = Guid.NewGuid();
+ var movie = Guid.NewGuid();
+ var alternateVersion = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, collections);
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddFolder(ctx, library);
+ AddItem(ctx, movie, MovieType);
+ AddItem(ctx, alternateVersion, MovieType);
+
+ AddAncestors(ctx, boxSet, collections);
+ AddAncestors(ctx, movie, library);
+ AddAncestors(ctx, alternateVersion, library);
+ // Only the second file carries the subtitles, and it hangs off the movie by an alternate
+ // version link. The movie is not a folder, so that link is not a parent-child edge.
+ AddLink(ctx, movie, alternateVersion, LinkedChildType.LocalAlternateVersion);
+ AddLink(ctx, boxSet, movie);
+ AddStream(ctx, alternateVersion, MediaStreamTypeEntity.Subtitle);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
+
+ // The library still matches: the alternate version carries its own closure. The box set does
+ // not, matching the descendant side, which does not follow a non-folder's links either.
+ Assert.Equal([library], folders);
+ }
+ }
+
+ [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 handing them back as AsQueryable() makes EF inline one
+ // literal per descendant, which is what allocated 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, LinkedChildType childType = LinkedChildType.Manual)
+ {
+ _linkCounters.TryGetValue(parentId, out var sortOrder);
+ _linkCounters[parentId] = sortOrder + 1;
+
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = parentId,
+ ChildId = childId,
+ ChildType = childType,
+ SortOrder = sortOrder
+ });
+ }
+}