aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests/Item
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Item')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs33
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs151
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs52
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs553
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs526
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs287
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs118
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs154
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs85
9 files changed, 1877 insertions, 82 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
index b7fca74310..2d520f8b8b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -4,11 +4,6 @@ using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
-using Jellyfin.Database.Implementations.Locking;
-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;
@@ -18,22 +13,10 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
/// and evaluate correctly on the SQLite provider.
/// </summary>
-public sealed class AlternateVersionQueryTranslationTests : IDisposable
+public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture
{
- private readonly SqliteConnection _connection;
- private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
-
public AlternateVersionQueryTranslationTests()
{
- _connection = new SqliteConnection("Data Source=:memory:");
- _connection.Open();
-
- _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
- .UseSqlite(_connection)
- .Options;
-
- using var ctx = CreateDbContext();
- ctx.Database.EnsureCreated();
}
[Fact]
@@ -220,18 +203,4 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
ctx.SaveChanges();
return (user.Id, primary.Id, versionA.Id, versionB.Id);
}
-
- private JellyfinDbContext CreateDbContext()
- {
- return new JellyfinDbContext(
- _dbOptions,
- NullLogger<JellyfinDbContext>.Instance,
- new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
- new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
- }
-
- public void Dispose()
- {
- _connection.Dispose();
- }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
new file mode 100644
index 0000000000..0cee47f660
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// The by-name endpoints (artists, album artists, genres, studios) all funnel through
+/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count
+/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total.
+/// </summary>
+public sealed class BaseItemRepositoryByNameTotalCountTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryByNameTotalCountTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetArtists_WithoutLimit_ReportsTotalRecordCount()
+ {
+ SeedArtists(3);
+
+ var result = _repository.GetArtists(CreateQuery(limit: null));
+
+ Assert.Equal(3, result.Items.Count);
+ Assert.Equal(3, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_WithLimit_ReportsTotalBeyondThePage()
+ {
+ SeedArtists(3);
+
+ var result = _repository.GetArtists(CreateQuery(limit: 2));
+
+ Assert.Equal(2, result.Items.Count);
+ Assert.Equal(3, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_TotalRecordCountDisabled_StaysZero()
+ {
+ SeedArtists(3);
+
+ var query = CreateQuery(limit: null);
+ query.EnableTotalRecordCount = false;
+
+ var result = _repository.GetArtists(query);
+
+ Assert.Equal(3, result.Items.Count);
+ Assert.Equal(0, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_WithoutLimit_DoesNotMutateCallerQuery()
+ {
+ SeedArtists(1);
+
+ var query = CreateQuery(limit: null);
+ Assert.True(query.EnableTotalRecordCount);
+
+ _repository.GetArtists(query);
+
+ // The repository used to flip this flag on the caller's own query object, so a
+ // reused query silently lost its total on every subsequent call.
+ Assert.True(query.EnableTotalRecordCount);
+ }
+
+ private static InternalItemsQuery CreateQuery(int? limit)
+ {
+ return new InternalItemsQuery(new User("test", "auth", "reset"))
+ {
+ Limit = limit
+ };
+ }
+
+ /// <summary>
+ /// Creates <paramref name="count"/> artists, each credited on one song, which is what
+ /// makes them visible to the item-value join behind the by-name endpoints.
+ /// </summary>
+ private void SeedArtists(int count)
+ {
+ using var ctx = CreateDbContext();
+
+ for (var i = 0; i < count; i++)
+ {
+ var name = $"Artist {i}";
+ var cleanName = name.ToLowerInvariant();
+
+ var artistId = Guid.Parse($"aaaaaaaa-0000-0000-0000-{i:D12}");
+ var songId = Guid.Parse($"55555555-0000-0000-0000-{i:D12}");
+ var valueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}");
+
+ var artist = new BaseItemEntity
+ {
+ Id = artistId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist],
+ Name = name,
+ CleanName = cleanName,
+ PresentationUniqueKey = artistId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ };
+
+ var song = new BaseItemEntity
+ {
+ Id = songId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio],
+ Name = $"Song {i}",
+ CleanName = $"song {i}",
+ PresentationUniqueKey = songId.ToString("N"),
+ MediaType = "Audio",
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = valueId,
+ Type = ItemValueType.Artist,
+ Value = name,
+ CleanValue = cleanName
+ };
+
+ ctx.BaseItems.Add(artist);
+ ctx.BaseItems.Add(song);
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = songId,
+ ItemValueId = valueId,
+ Item = song,
+ ItemValue = itemValue
+ });
+ }
+
+ ctx.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
index 083f725db9..535961a66c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -3,64 +3,25 @@ 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 MediaBrowser.Model.Entities;
-using Microsoft.Data.Sqlite;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
namespace Jellyfin.Server.Implementations.Tests.Item;
-public sealed class BaseItemRepositoryGroupingTests : IDisposable
+public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
{
- private readonly SqliteConnection _connection;
- private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly BaseItemRepository _repository;
private readonly string _movieTypeName;
public BaseItemRepositoryGroupingTests()
{
- _connection = new SqliteConnection("Data Source=:memory:");
- _connection.Open();
-
- _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
- .UseSqlite(_connection)
- .Options;
-
- using (var ctx = CreateDbContext())
- {
- ctx.Database.EnsureCreated();
- }
-
- var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
- factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
-
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
- var serverConfigurationManager = new Mock<IServerConfigurationManager>();
- serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
-
- _repository = new BaseItemRepository(
- factory.Object,
- new Mock<IServerApplicationHost>().Object,
- itemTypeLookup,
- serverConfigurationManager.Object,
- NullLogger<BaseItemRepository>.Instance);
- }
-
- public void Dispose()
- {
- _connection.Dispose();
+ _repository = CreateBaseItemRepository(itemTypeLookup);
}
[Fact]
@@ -132,13 +93,4 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable
IsVirtualItem = false
};
}
-
- private JellyfinDbContext CreateDbContext()
- {
- return 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/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
new file mode 100644
index 0000000000..4e8d84850b
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
@@ -0,0 +1,553 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the filters resolving "folders with a matching descendant" through
+/// <see cref="DescendantQueryHelper.GetFolderIdsMatching"/>, positive and negated.
+/// </summary>
+public sealed class BaseItemRepositoryStreamFilterTests : 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 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();
+
+ // A version group in a library of its own, so it cannot move the assertions above: an SD primary
+ // that carries nothing, and a 4K second file carrying the subtitles, chapter image and audio.
+ private readonly Guid _versionLibrary = Guid.NewGuid();
+ private readonly Guid _versionedMovie = Guid.NewGuid();
+ private readonly Guid _alternateVersion = Guid.NewGuid();
+
+ // A series in the same library, so the folder branch of the resolution filter has a version group
+ // to reach through as well: an SD episode whose second file is 4K.
+ private readonly Guid _versionedSeries = Guid.NewGuid();
+ private readonly Guid _versionedEpisode = Guid.NewGuid();
+ private readonly Guid _episodeAlternate = Guid.NewGuid();
+
+ // An unprobed primary: only its second file carries dimensions, and they are SD.
+ private readonly Guid _unprobedMovie = Guid.NewGuid();
+ private readonly Guid _unprobedAlternate = Guid.NewGuid();
+
+ // A plain SD movie with no second file, as the control the version groups are read against.
+ private readonly Guid _sdMovie = Guid.NewGuid();
+
+ // An unprobed primary whose only second file is HD, so the HD bucket has to place it off nulls.
+ private readonly Guid _hdOnlyByVersion = Guid.NewGuid();
+ private readonly Guid _hdOnlyAlternate = Guid.NewGuid();
+
+ // Three files for one movie: the HD one would place it in the HD bucket on its own, the 4K one has
+ // to win. Only a group holding both can tell the HD bucket's upper guard from its lower one.
+ private readonly Guid _threeWayMovie = Guid.NewGuid();
+ private readonly Guid _threeWayHd = Guid.NewGuid();
+ private readonly Guid _threeWay4K = Guid.NewGuid();
+
+ public BaseItemRepositoryStreamFilterTests()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ Seed(ctx);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [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 });
+
+ 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);
+ }
+
+ [Fact]
+ public void HasSubtitles_MatchesAnItemWhoseAlternateVersionCarriesThem()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
+
+ Assert.Contains(_versionedMovie, ids);
+ Assert.Contains(_versionLibrary, ids);
+ // The second file is never listed on its own, which is why its tracks have to count for the primary.
+ Assert.DoesNotContain(_alternateVersion, ids);
+ }
+
+ [Fact]
+ public void HasSubtitles_Negated_ExcludesAnItemWhoseAlternateVersionCarriesThem()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
+
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.DoesNotContain(_versionLibrary, ids);
+ }
+
+ [Fact]
+ public void SubtitleLanguages_MatchesTheLanguageOnAnAlternateVersion()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
+ }
+
+ [Fact]
+ public void HasNoSubtitleTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
+
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.DoesNotContain(_versionLibrary, ids);
+ }
+
+ [Fact]
+ public void AudioLanguages_MatchesTheLanguageOnAnAlternateVersion()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { AudioLanguages = ["fre"] }));
+ }
+
+ [Fact]
+ public void HasNoAudioTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
+ {
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = "fre" }));
+ }
+
+ [Fact]
+ public void HasChapterImages_MatchesAnItemWhoseAlternateVersionCarriesThem()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true }));
+ }
+
+ [Fact]
+ public void Is4K_MatchesAnItemWhoseAlternateVersionIs4K()
+ {
+ // The primary file is SD; the resolution a caller can actually play is the 4K second file's.
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
+ }
+
+ [Fact]
+ public void MinWidth_MatchesAnItemWhoseAlternateVersionIsWideEnough()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
+ Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
+ }
+
+ [Fact]
+ public void MaxWidth_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
+ {
+ // The SD primary is narrow enough on its own, but the 4K second file is what a caller would play.
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
+ Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
+ }
+
+ [Fact]
+ public void MaxHeight_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
+ {
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
+ Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
+ }
+
+ [Fact]
+ public void IsHD_False_ExcludesAnSdPrimaryWhoseAlternateVersionIsBetter()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false });
+
+ // 720x480 on its own, but the version group tops out at 4K.
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.Contains(_sdMovie, ids);
+ }
+
+ [Fact]
+ public void IsHD_False_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
+ {
+ // The primary carries no dimensions at all; the SD second file is the group's best.
+ Assert.Contains(_unprobedMovie, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
+ }
+
+ [Fact]
+ public void IsHD_True_ExcludesAnItemWhoseVersionGroupReaches4K()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true });
+
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.DoesNotContain(_unprobedMovie, ids);
+ // The 1920-wide second file alone would say HD; the 4K third file is the group's best.
+ Assert.DoesNotContain(_threeWayMovie, ids);
+ }
+
+ [Fact]
+ public void Is4K_MatchesAnItemWhoseVersionGroupHoldsBothHdAnd4K()
+ {
+ Assert.Contains(_threeWayMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
+ }
+
+ [Fact]
+ public void IsHD_True_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
+ {
+ // The primary carries no dimensions of its own; the HD second file is the group's best.
+ Assert.Contains(_hdOnlyByVersion, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true }));
+ }
+
+ [Fact]
+ public void Is4K_MatchesTheSeriesOfAnEpisodeWhoseAlternateVersionIs4K()
+ {
+ // The folder branch buckets a descendant the same way the item branch buckets a top-level item.
+ Assert.Contains(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
+ }
+
+ [Fact]
+ public void IsHD_False_ExcludesTheSeriesOfAnSdEpisodeWithABetterAlternateVersion()
+ {
+ // Before the version group was consulted on descendants too, the SD episode alone matched here
+ // while the same pair at top level did not.
+ Assert.DoesNotContain(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
+ }
+
+ [Theory]
+ [InlineData("und")]
+ [InlineData("UND")]
+ public void HasNoAudioTrackWithLanguage_TreatsUndeterminedCaseInsensitively(string language)
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = language });
+
+ // The alternate version carries an audio track with no language, which is what "und" stands for,
+ // so the item it is reported against does have one.
+ Assert.DoesNotContain(_unprobedMovie, ids);
+ Assert.Contains(_versionedMovie, 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!
+ });
+
+ SeedVersionGroup(context);
+
+ context.SaveChanges();
+ }
+
+ // An SD primary whose only extras live on a 4K second file, so every filter has to reach through
+ // PrimaryVersionId to answer correctly.
+ private void SeedVersionGroup(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionLibrary, Type = FolderType, Name = "Version library", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionedMovie, Type = MovieType, Name = "Versioned movie", Width = 720, Height = 480 });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _alternateVersion,
+ Type = MovieType,
+ Name = "Versioned movie 4K",
+ PrimaryVersionId = _versionedMovie,
+ Width = 3840,
+ Height = 2160
+ });
+
+ foreach (var itemId in new[] { _versionedMovie, _alternateVersion })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _alternateVersion,
+ StreamIndex = 0,
+ StreamType = MediaStreamTypeEntity.Subtitle,
+ Language = "ger",
+ Item = null!
+ });
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _alternateVersion,
+ StreamIndex = 1,
+ StreamType = MediaStreamTypeEntity.Audio,
+ Language = "fre",
+ Item = null!
+ });
+
+ SeedVersionedSeries(context);
+ SeedUnprobedVersionGroup(context);
+
+ context.Chapters.Add(new Chapter
+ {
+ ItemId = _alternateVersion,
+ ChapterIndex = 0,
+ StartPositionTicks = 0,
+ ImagePath = "/alternate-chapter.jpg",
+ Item = null!
+ });
+ }
+
+ // The same SD primary / 4K second file pair one level down, so the resolution filter has to answer
+ // for the series off its descendants.
+ private void SeedVersionedSeries(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionedSeries, Type = FolderType, Name = "Versioned series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionedEpisode, Type = MovieType, Name = "Versioned episode", Width = 720, Height = 480 });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _episodeAlternate,
+ Type = MovieType,
+ Name = "Versioned episode 4K",
+ PrimaryVersionId = _versionedEpisode,
+ Width = 3840,
+ Height = 2160
+ });
+
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = _versionedSeries,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+
+ foreach (var itemId in new[] { _versionedEpisode, _episodeAlternate })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionedSeries,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ // A primary that was never probed, so only its second file can place it in a bucket. Its audio track
+ // declares no language, which is what the "und" filters stand in for.
+ private void SeedUnprobedVersionGroup(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _sdMovie, Type = MovieType, Name = "SD movie", Width = 720, Height = 480 });
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = _sdMovie,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+
+ context.BaseItems.Add(new BaseItemEntity { Id = _unprobedMovie, Type = MovieType, Name = "Unprobed movie" });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _unprobedAlternate,
+ Type = MovieType,
+ Name = "Unprobed movie SD",
+ PrimaryVersionId = _unprobedMovie,
+ Width = 720,
+ Height = 480
+ });
+
+ foreach (var itemId in new[] { _unprobedMovie, _unprobedAlternate })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _unprobedAlternate,
+ StreamIndex = 0,
+ StreamType = MediaStreamTypeEntity.Audio,
+ Item = null!
+ });
+
+ SeedMixedVersionGroups(context);
+ }
+
+ // The two groups that separate the HD bucket's lower bound from its upper one: one that only a 4K
+ // third file keeps out of HD, and one that only an HD second file puts into it.
+ private void SeedMixedVersionGroups(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _threeWayMovie, Type = MovieType, Name = "Three-way movie", Width = 720, Height = 480 });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _threeWayHd,
+ Type = MovieType,
+ Name = "Three-way movie HD",
+ PrimaryVersionId = _threeWayMovie,
+ Width = 1920,
+ Height = 1080
+ });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _threeWay4K,
+ Type = MovieType,
+ Name = "Three-way movie 4K",
+ PrimaryVersionId = _threeWayMovie,
+ Width = 3840,
+ Height = 2160
+ });
+
+ context.BaseItems.Add(new BaseItemEntity { Id = _hdOnlyByVersion, Type = MovieType, Name = "HD only by version" });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _hdOnlyAlternate,
+ Type = MovieType,
+ Name = "HD only by version, HD file",
+ PrimaryVersionId = _hdOnlyByVersion,
+ Width = 1920,
+ Height = 1080
+ });
+
+ foreach (var itemId in new[] { _threeWayMovie, _threeWayHd, _threeWay4K, _hdOnlyByVersion, _hdOnlyAlternate })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+}
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
+ });
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
new file mode 100644
index 0000000000..947cf54d85
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -0,0 +1,287 @@
+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.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+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;
+
+public sealed class ItemCountServiceTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ItemCountService _service;
+
+ public ItemCountServiceTests()
+ {
+ _applicationPaths = new Mock<IApplicationPaths>().Object;
+
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var context = CreateDbContext())
+ {
+ context.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var queryHelpers = new Mock<IItemQueryHelpers>();
+ queryHelpers
+ .Setup(h => h.ApplyAccessFiltering(
+ It.IsAny<JellyfinDbContext>(),
+ It.IsAny<IQueryable<BaseItemEntity>>(),
+ It.IsAny<InternalItemsQuery>()))
+ .Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query);
+
+ _service = new ItemCountService(
+ factory.Object,
+ new Mock<IItemTypeLookup>().Object,
+ queryHelpers.Object);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit()
+ {
+ var hierarchicalParentId = Guid.NewGuid();
+ var linkedParentId = Guid.NewGuid();
+
+ var hierarchicalChildId = Guid.NewGuid();
+ var linkedChildId1 = Guid.NewGuid();
+ var linkedChildId2 = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ CreateItem(hierarchicalParentId),
+ CreateItem(linkedParentId),
+ CreateItem(hierarchicalChildId, hierarchicalParentId),
+ CreateItem(linkedChildId1),
+ CreateItem(linkedChildId2));
+
+ context.LinkedChildren.AddRange(
+ new LinkedChildEntity
+ {
+ ParentId = linkedParentId,
+ ChildId = linkedChildId1,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = linkedParentId,
+ ChildId = linkedChildId2,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 1
+ });
+
+ context.SaveChanges();
+ }
+
+ var parentIds = Enumerable.Range(0, 40_000)
+ .Select(_ => Guid.NewGuid())
+ .ToList();
+
+ parentIds.Add(hierarchicalParentId);
+ parentIds.Add(linkedParentId);
+
+ var result = _service.GetChildCountBatch(parentIds, null);
+
+ Assert.Equal(1, result[hierarchicalParentId]);
+ Assert.Equal(2, result[linkedParentId]);
+ Assert.Equal(parentIds.Count, result.Count);
+ }
+
+ [Fact]
+ public void GetCounts_MergedFolders_CountLeavesOfEveryFolderInTheGroup()
+ {
+ // Two folder-items of one merged series: same presentation key, a leaf each, one of them played.
+ var (user, seriesA, seriesB) = SeedMergedSeries(out var playedLeafId);
+
+ var filter = new InternalItemsQuery(user);
+
+ // Either folder-item stands for the whole merged series, so both must report the group.
+ foreach (var seriesId in new[] { seriesA, seriesB })
+ {
+ Assert.Equal(2, _service.GetTotalCount(filter, seriesId));
+ Assert.Equal(1, _service.GetPlayedCount(filter, seriesId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, seriesId));
+ }
+
+ var batch = _service.GetPlayedAndTotalCountBatch([seriesA], user);
+ Assert.Equal((1, 2), batch[seriesA]);
+
+ Assert.NotEqual(Guid.Empty, playedLeafId);
+ }
+
+ [Fact]
+ public void GetCounts_UnmergedFolder_CountsOnlyItsOwnLeaves()
+ {
+ var (user, _, _) = SeedMergedSeries(out _);
+
+ // A folder with a key of its own must not pick up anything from the merged pair.
+ var loneSeriesId = Guid.NewGuid();
+ var loneLeafId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ var lone = CreateItem(loneSeriesId);
+ lone.PresentationUniqueKey = "lone-series";
+ context.BaseItems.Add(lone);
+ context.BaseItems.Add(CreateLeaf(loneLeafId));
+ context.SaveChanges();
+ AddAncestor(context, loneLeafId, loneSeriesId);
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ Assert.Equal(1, _service.GetTotalCount(filter, loneSeriesId));
+ Assert.Equal(0, _service.GetPlayedCount(filter, loneSeriesId));
+ Assert.Equal((0, 1), _service.GetPlayedAndTotalCount(filter, loneSeriesId));
+ }
+
+ [Fact]
+ public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys()
+ {
+ var seriesA = Guid.NewGuid();
+ var seriesB = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ foreach (var id in new[] { seriesA, seriesB })
+ {
+ var series = CreateItem(id);
+ series.PresentationUniqueKey = "merged-series";
+ context.BaseItems.Add(series);
+ }
+
+ // Each folder-item holds a "Season 1"; those two share a key and are one season to the user.
+ var sharedSeasonA = CreateItem(Guid.NewGuid(), seriesA);
+ sharedSeasonA.PresentationUniqueKey = "merged-series-001";
+ var sharedSeasonB = CreateItem(Guid.NewGuid(), seriesB);
+ sharedSeasonB.PresentationUniqueKey = "merged-series-001";
+ var ownSeason = CreateItem(Guid.NewGuid(), seriesB);
+ ownSeason.PresentationUniqueKey = "merged-series-002";
+
+ context.BaseItems.AddRange(sharedSeasonA, sharedSeasonB, ownSeason);
+ context.SaveChanges();
+ }
+
+ var result = _service.GetChildCountBatch([seriesA, seriesB], null);
+
+ Assert.Equal(2, result[seriesA]);
+ Assert.Equal(2, result[seriesB]);
+ }
+
+ private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
+ {
+ var user = new User("count-test", "provider", "reset");
+ var seriesA = Guid.NewGuid();
+ var seriesB = Guid.NewGuid();
+ var leafA = Guid.NewGuid();
+ var leafB = Guid.NewGuid();
+ playedLeafId = leafA;
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ foreach (var id in new[] { seriesA, seriesB })
+ {
+ var series = CreateItem(id);
+ series.PresentationUniqueKey = "merged-series";
+ context.BaseItems.Add(series);
+ }
+
+ context.BaseItems.AddRange(CreateLeaf(leafA), CreateLeaf(leafB));
+ context.SaveChanges();
+
+ AddAncestor(context, leafA, seriesA);
+ AddAncestor(context, leafB, seriesB);
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = leafA,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ return (user, seriesA, seriesB);
+ }
+
+ private static void AddAncestor(JellyfinDbContext context, Guid itemId, Guid parentItemId)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = parentItemId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ private static BaseItemEntity CreateLeaf(Guid id)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Episode",
+ IsFolder = false,
+ IsVirtualItem = false,
+ PresentationUniqueKey = id.ToString("N")
+ };
+ }
+
+ private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Folder",
+ ParentId = parentId,
+ IsFolder = true
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(
+ _applicationPaths,
+ NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
new file mode 100644
index 0000000000..82614c3156
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IServerConfigurationManager? _previousConfigurationManager;
+
+ public ItemPersistenceOwnedRowTests()
+ {
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousConfigurationManager = BaseItem.ConfigurationManager;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>()))
+ .Returns([]);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configurationManager.Object;
+
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ new Mock<IServerApplicationHost>().Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ base.Dispose(disposing);
+ }
+
+ [Fact]
+ public void SaveItems_UpdateExistingItem_ReplacesOwnedRows()
+ {
+ var id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+
+ _service.SaveItems(
+ [CreateBook(id, new() { ["Imdb"] = "tt0001", ["Tmdb"] = "555" }, [MetadataField.Name])],
+ CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ Assert.Equal(2, ctx.BaseItemProviders.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(1, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id)));
+ }
+
+ // Re-save with different owned rows: the update path rewrites all three tables wholesale.
+ _service.SaveItems(
+ [CreateBook(id, new() { ["Imdb"] = "tt9999" }, [MetadataField.Name, MetadataField.Genres])],
+ CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ var providers = ctx.BaseItemProviders.Where(e => e.ItemId.Equals(id)).ToList();
+ Assert.Equal("tt9999", Assert.Single(providers).ProviderValue);
+
+ Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(2, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id)));
+ }
+ }
+
+ [Fact]
+ public void SaveItems_MixedNewAndExistingBatch_ReplacesOnlyExistingOwnedRows()
+ {
+ var existing = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ var fresh = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+
+ _service.SaveItems([CreateBook(existing, new() { ["Imdb"] = "tt0001" }, [])], CancellationToken.None);
+
+ // One already-persisted item and one brand new item in the same batch.
+ _service.SaveItems(
+ [
+ CreateBook(existing, new() { ["Imdb"] = "tt0002" }, []),
+ CreateBook(fresh, new() { ["Tmdb"] = "777" }, [])
+ ],
+ CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ Assert.Equal("tt0002", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(existing))).ProviderValue);
+ Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue);
+ }
+
+ private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields)
+ {
+ var book = new Book
+ {
+ Id = id,
+ Name = "Book",
+ ProviderIds = providerIds,
+ LockedFields = lockedFields
+ };
+
+ book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
+ return book;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
new file mode 100644
index 0000000000..54565c5787
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture
+{
+ private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+
+ private readonly PeopleRepository _repository;
+
+ public PeopleRepositoryUpdatePeopleTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _itemId,
+ Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie],
+ Name = "Movie",
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ });
+ ctx.SaveChanges();
+ }
+
+ _repository = new PeopleRepository(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ new Mock<IItemQueryHelpers>().Object);
+ }
+
+ [Fact]
+ public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel"),
+ CreatePerson("Person A", PersonKind.Writer, "Screenplay")
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Single(ctx.Peoples);
+ Assert.Equal(
+ ["Novel", "Screenplay"],
+ ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
+ }
+
+ [Fact]
+ public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("person a", PersonKind.Actor, "hero")
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Single(ctx.Peoples);
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Hero", map.Role);
+ }
+
+ [Fact]
+ public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("Person A", PersonKind.Director, string.Empty)
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Equal(2, ctx.Peoples.Count());
+ Assert.Equal(2, ctx.PeopleBaseItemMap.Count());
+ }
+
+ [Fact]
+ public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("Person B", PersonKind.Actor, "Sidekick")
+ ]);
+
+ Guid[] peopleIdsBefore;
+ using (var ctx = CreateDbContext())
+ {
+ peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray();
+ }
+
+ // Reversed order, so the list order of both mappings has to be rewritten.
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person B", PersonKind.Actor, "Sidekick"),
+ CreatePerson("Person A", PersonKind.Actor, "Hero")
+ ]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray());
+ Assert.Equal(
+ ["Sidekick", "Hero"],
+ after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
+ }
+
+ [Fact]
+ public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel"),
+ CreatePerson("Person A", PersonKind.Writer, "Screenplay")
+ ]);
+
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel")
+ ]);
+
+ using var ctx = CreateDbContext();
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Novel", map.Role);
+ }
+
+ [Fact]
+ public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping()
+ {
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]);
+
+ using var ctx = CreateDbContext();
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Hero", map.Role);
+ }
+
+ private static PersonInfo CreatePerson(string name, PersonKind type, string role)
+ {
+ return new PersonInfo
+ {
+ Name = name,
+ Type = type,
+ Role = role
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
new file mode 100644
index 0000000000..87efa8fea5
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -0,0 +1,85 @@
+using System;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Base fixture for the item tests that run against the SQLite provider: one in-memory database per
+/// test class, plus the wiring the repositories under test need. The connection owns the database, so
+/// it stays open for the lifetime of the fixture. Derived classes seed in their own constructor.
+/// </summary>
+public abstract class SqliteDbTestFixture : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+
+ protected SqliteDbTestFixture()
+ {
+ ApplicationPaths = new Mock<IApplicationPaths>().Object;
+
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ protected IApplicationPaths ApplicationPaths { get; }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+
+ protected IDbContextFactory<JellyfinDbContext> CreateDbContextFactory()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ return factory.Object;
+ }
+
+ protected BaseItemRepository CreateBaseItemRepository(ItemTypeLookup itemTypeLookup)
+ {
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+
+ return new BaseItemRepository(
+ CreateDbContextFactory(),
+ new Mock<IServerApplicationHost>().Object,
+ itemTypeLookup,
+ serverConfigurationManager.Object,
+ NullLogger<BaseItemRepository>.Instance);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _connection.Dispose();
+ }
+ }
+}