Optimize query helper memory
This commit is contained in:
+224
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user