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;
///
/// 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.
///
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 _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 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
});
}
}