using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; 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; 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 _dbOptions; private readonly IApplicationPaths _applicationPaths; private readonly ItemCountService _service; private int _contextsCreated; private List? _capturedSql; public ItemCountServiceTests() { _applicationPaths = new Mock().Object; _connection = new SqliteConnection("Data Source=:memory:"); _connection.Open(); _dbOptions = new DbContextOptionsBuilder() .UseSqlite(_connection) .LogTo(CaptureStatement, LogLevel.Information) .Options; using (var context = CreateDbContext()) { context.Database.EnsureCreated(); } var factory = new Mock>(); factory.Setup(f => f.CreateDbContext()).Returns(() => { _contextsCreated++; return CreateDbContext(); }); var queryHelpers = new Mock(); queryHelpers .Setup(h => h.ApplyAccessFiltering( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns((JellyfinDbContext _, IQueryable query, InternalItemsQuery _) => query); var typeLookup = new Mock(); typeLookup.Setup(l => l.BaseItemKindNames).Returns(new Dictionary { [BaseItemKind.Movie] = "Movie", [BaseItemKind.Series] = "Series", [BaseItemKind.Episode] = "Episode", [BaseItemKind.MusicAlbum] = "MusicAlbum", [BaseItemKind.MusicArtist] = "MusicArtist", [BaseItemKind.MusicVideo] = "MusicVideo", [BaseItemKind.Audio] = "Audio", [BaseItemKind.Trailer] = "Trailer", [BaseItemKind.BoxSet] = "BoxSet", [BaseItemKind.Book] = "Book", [BaseItemKind.LiveTvProgram] = "LiveTvProgram" }); _service = new ItemCountService( factory.Object, typeLookup.Object, queryHelpers.Object); } public void Dispose() { _connection.Dispose(); } private void CaptureStatement(string message) { if (_capturedSql is not null && message.Contains("SELECT", StringComparison.Ordinal)) { _capturedSql.Add(message[message.IndexOf("SELECT", StringComparison.Ordinal)..]); } } [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]); } [Fact] public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason() { var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false); var result = _service.GetChildCountBatch([seriesId, seasonId], null); Assert.Equal(2, result[seasonId]); // The series holds the season, not the episodes: counting those here would double them up. Assert.Equal(1, result[seriesId]); } [Fact] public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce() { var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false); var result = _service.GetChildCountBatch([seriesId, seasonId], null); Assert.Equal(2, result[seasonId]); Assert.Equal(1, result[seriesId]); } [Fact] public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem() { var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true); var user = new User("count-test", "provider", "reset"); user.DisplayMissingEpisodes = true; Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]); // Nothing this user can open, so nothing to report. user.DisplayMissingEpisodes = false; Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]); } [Fact] public void GetChildCountBatch_NoUser_CountsMissingEpisodes() { var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true); Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]); } private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes) { var seriesId = Guid.NewGuid(); var seasonId = Guid.NewGuid(); using var context = CreateDbContext(); context.BaseItems.Add(CreateItem(seriesId)); context.BaseItems.Add(CreateItem(seasonId, seriesId)); // Flat: the episodes sit in the series folder, so ParentId points at the series and only // SeasonId ties them to the season they belong to. for (var i = 0; i < 2; i++) { var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId); episode.Type = "MediaBrowser.Controller.Entities.TV.Episode"; episode.IsFolder = false; episode.IsVirtualItem = virtualEpisodes; episode.SeasonId = seasonId; context.BaseItems.Add(episode); } context.SaveChanges(); return (seriesId, seasonId); } 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") }; } [Fact] public void GetItemCountsForNameItems_MatchesCountingEachNameItemOnItsOwn() { // Three genres tagging a different number of movies each, plus one tagging nothing. var genres = SeedGenres(); var filter = new InternalItemsQuery(); BaseItemKind[] related = [BaseItemKind.Movie, BaseItemKind.Series]; var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); // Every requested id is answered, so a caller can index the result without checking. Assert.Equal(genres.Count, batch.Count); foreach (var genreId in genres) { var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); Assert.Equal(single.MovieCount, batch[genreId].MovieCount); Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); Assert.Equal(single.ItemCount, batch[genreId].ItemCount); } // And the counts are the seeded ones rather than all zero, which would match trivially. Assert.Equal([3, 2, 1, 0], genres.Select(g => batch[g].MovieCount).ToArray()); } [Fact] public void GetItemCountsForNameItems_UnknownId_CountsZero() { var unknown = Guid.NewGuid(); var batch = _service.GetItemCountsForNameItems( BaseItemKind.Genre, [unknown], [BaseItemKind.Movie], new InternalItemsQuery()); Assert.Equal(0, batch[unknown].ItemCount); } [Fact] public void GetItemCountsForNameItems_ArtistTaggedTwiceOnOneAlbum_CountsTheAlbumOnce() { // An album whose artist is also its album artist maps to the same artist twice. var artistId = SeedArtistWithAlbum(); var filter = new InternalItemsQuery(); BaseItemKind[] related = [BaseItemKind.MusicAlbum]; var batch = _service.GetItemCountsForNameItems(BaseItemKind.MusicArtist, [artistId], related, filter); var single = _service.GetItemCountsForNameItem(BaseItemKind.MusicArtist, artistId, related, filter); Assert.Equal(1, batch[artistId].AlbumCount); Assert.Equal(single.AlbumCount, batch[artistId].AlbumCount); Assert.Equal(single.ItemCount, batch[artistId].ItemCount); } /// /// Seeds one artist and a single album tagged with it as both artist and album artist. /// /// The id of the seeded artist. private Guid SeedArtistWithAlbum() { const string Name = "artist-0"; var artistId = Guid.NewGuid(); var albumId = Guid.NewGuid(); using var context = CreateDbContext(); var artist = CreateItem(artistId); artist.Type = "MusicArtist"; artist.Name = Name; artist.CleanName = Name; context.BaseItems.Add(artist); var album = CreateItem(albumId); album.Type = "MusicAlbum"; context.BaseItems.Add(album); context.SaveChanges(); foreach (var type in new[] { ItemValueType.Artist, ItemValueType.AlbumArtist }) { var itemValue = new ItemValue { ItemValueId = Guid.NewGuid(), Type = type, Value = Name, CleanValue = Name }; context.ItemValues.Add(itemValue); context.SaveChanges(); context.ItemValuesMap.Add(new ItemValueMap { ItemId = albumId, ItemValueId = itemValue.ItemValueId, Item = null!, ItemValue = null! }); } context.SaveChanges(); return artistId; } [Fact] public void GetItemCountsForNameItems_LargeIdSet_DoesNotExceedSqliteVariableLimit() { // Seeded rather than random, so the clean names of every one of them reach the second // query's IN list and the join behind it, instead of stopping at the empty-name return. var seeded = SeedArtists(50, out var taggedArtistId); var ids = seeded.Concat(Enumerable.Range(0, 40_000).Select(_ => Guid.NewGuid())).ToList(); var batch = _service.GetItemCountsForNameItems( BaseItemKind.MusicArtist, ids, [BaseItemKind.MusicAlbum], new InternalItemsQuery()); Assert.Equal(ids.Count, batch.Count); // And the grouped query really ran, rather than every id coming back zeroed. Assert.Equal(1, batch[taggedArtistId].AlbumCount); } [Fact] public void GetItemCountsForNameItems_QueryShape_DoesNotVaryWithBatchSize() { // Every id list has to be bound as one parameter rather than one placeholder each: that is // what keeps the statement off the SQLite variable ceiling and out of a per-size entry in // EF's compiled query cache. Identical SQL for two batch sizes is exactly that property. var seeded = SeedArtists(6, out _); var small = CaptureSql(() => _service.GetItemCountsForNameItems( BaseItemKind.MusicArtist, seeded.Take(2).ToList(), [BaseItemKind.MusicAlbum], new InternalItemsQuery())); var large = CaptureSql(() => _service.GetItemCountsForNameItems( BaseItemKind.MusicArtist, seeded, [BaseItemKind.MusicAlbum], new InternalItemsQuery())); Assert.NotEmpty(small); Assert.Equal(small, large); } private List CaptureSql(Action action) { _capturedSql = []; try { action(); return _capturedSql; } finally { _capturedSql = null; } } /// /// Seeds the requested number of artists, each with a clean name of its own, one of which is /// credited on a single album. /// /// The number of artists to seed. /// The id of the artist credited on an album. /// The ids of the seeded artists. private List SeedArtists(int count, out Guid taggedArtistId) { var ids = new List(count); using var context = CreateDbContext(); ItemValue? taggedValue = null; taggedArtistId = Guid.Empty; for (var i = 0; i < count; i++) { var name = "bulk-artist-" + i.ToString(CultureInfo.InvariantCulture); var artistId = Guid.NewGuid(); ids.Add(artistId); var artist = CreateItem(artistId); artist.Type = "MusicArtist"; artist.Name = name; artist.CleanName = name; context.BaseItems.Add(artist); if (i == 0) { taggedArtistId = artistId; taggedValue = new ItemValue { ItemValueId = Guid.NewGuid(), Type = ItemValueType.Artist, Value = name, CleanValue = name }; context.ItemValues.Add(taggedValue); } } context.SaveChanges(); var albumId = Guid.NewGuid(); var album = CreateItem(albumId); album.Type = "MusicAlbum"; context.BaseItems.Add(album); context.SaveChanges(); Tag(context, albumId, taggedValue!.ItemValueId); context.SaveChanges(); return ids; } [Fact] public void GetItemCountsForNameItems_KindWithoutItemValues_FallsBackToTheSingleItemPath() { // Year is keyed by ProductionYear rather than a cleaned item value, so it cannot be grouped. var yearId = Guid.NewGuid(); using (var context = CreateDbContext()) { var year = CreateItem(yearId); year.Type = "Year"; year.Name = "2001"; year.CleanName = "2001"; context.BaseItems.Add(year); for (var i = 0; i < 2; i++) { var movie = CreateItem(Guid.NewGuid()); movie.Type = "Movie"; movie.IsFolder = false; movie.ProductionYear = 2001; context.BaseItems.Add(movie); } context.SaveChanges(); } var filter = new InternalItemsQuery(); BaseItemKind[] related = [BaseItemKind.Movie]; var batch = _service.GetItemCountsForNameItems(BaseItemKind.Year, [yearId], related, filter); var single = _service.GetItemCountsForNameItem(BaseItemKind.Year, yearId, related, filter); Assert.Equal(2, batch[yearId].MovieCount); Assert.Equal(single.MovieCount, batch[yearId].MovieCount); } [Fact] public void GetItemCountsForNameItems_PeopleAndYears_AreBatchedToo() { var (personIds, yearIds) = SeedPeopleAndYears(); var filter = new InternalItemsQuery(); BaseItemKind[] related = [BaseItemKind.Movie]; foreach (var (kind, ids) in new[] { (BaseItemKind.Person, personIds), (BaseItemKind.Year, yearIds) }) { var contextsBefore = _contextsCreated; var batch = _service.GetItemCountsForNameItems(kind, ids, related, filter); // These two used to be answered one query per id; only the value keyed kinds batched. Assert.Equal(1, _contextsCreated - contextsBefore); Assert.Equal(ids.Count, batch.Count); Assert.Equal(2, batch[ids[0]].MovieCount); Assert.Equal(1, batch[ids[1]].MovieCount); foreach (var id in ids) { var single = _service.GetItemCountsForNameItem(kind, id, related, filter); Assert.Equal(single.MovieCount, batch[id].MovieCount); Assert.Equal(single.ItemCount, batch[id].ItemCount); } } } /// /// Seeds two people and two years, the first of each on two movies and the second on one. /// /// The ids of the seeded people and years. private (List PersonIds, List YearIds) SeedPeopleAndYears() { var personIds = new List(); var yearIds = new List(); using var context = CreateDbContext(); for (var i = 0; i < 2; i++) { var personName = "person-" + i.ToString(CultureInfo.InvariantCulture); var personId = Guid.NewGuid(); personIds.Add(personId); var person = CreateItem(personId); person.Type = "Person"; person.Name = personName; person.CleanName = personName; context.BaseItems.Add(person); var people = new People { Id = Guid.NewGuid(), Name = personName }; context.Peoples.Add(people); var year = 2000 + i; var yearId = Guid.NewGuid(); yearIds.Add(yearId); var yearItem = CreateItem(yearId); yearItem.Type = "Year"; yearItem.Name = year.ToString(CultureInfo.InvariantCulture); yearItem.CleanName = yearItem.Name; context.BaseItems.Add(yearItem); context.SaveChanges(); // Two movies for the first of each, one for the second. for (var m = 0; m < 2 - i; m++) { var movieId = Guid.NewGuid(); var movie = CreateItem(movieId); movie.Type = "Movie"; movie.IsFolder = false; movie.ProductionYear = year; context.BaseItems.Add(movie); context.SaveChanges(); context.PeopleBaseItemMap.Add(new PeopleBaseItemMap { ItemId = movieId, PeopleId = people.Id, Item = null!, People = null!, Role = "Actor", ListOrder = m, SortOrder = m }); } context.SaveChanges(); } return (personIds, yearIds); } [Theory] // The set the by-name listing actually asks for: it rolls the episodes of a tagged series up // into the genre, which is the case the batch has to reproduce query for query. [InlineData(BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie)] // And the same seeded data without the roll-up, which takes the plain grouped path. [InlineData(BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicAlbum)] public void GetItemCountsForNameItems_TaggedSeriesAndEpisodes_MatchesCountingEachNameItemOnItsOwn( BaseItemKind first, BaseItemKind second, BaseItemKind third) { var genres = SeedGenresTaggingSeriesAndEpisodes(); var filter = new InternalItemsQuery(); BaseItemKind[] related = [first, second, third]; var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); Assert.Equal(genres.Count, batch.Count); foreach (var genreId in genres) { var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); Assert.Equal(single.MovieCount, batch[genreId].MovieCount); Assert.Equal(single.ItemCount, batch[genreId].ItemCount); } } [Fact] public void GetItemCountsForNameItems_TaggedSeries_RollsEpisodesUpIntoTheGenre() { var genres = SeedGenresTaggingSeriesAndEpisodes(); var contextsBefore = _contextsCreated; var batch = _service.GetItemCountsForNameItems( BaseItemKind.Genre, genres, [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie], new InternalItemsQuery()); // The whole point of the batch: one context for every genre on the page, not one each. // The roll-up used to force this shape back onto the single item path. Assert.Equal(1, _contextsCreated - contextsBefore); // "rolled": one tagged series of two episodes, one of which carries the genre itself, plus // a loose tagged episode of an untagged series. The tagged episode of the tagged series // must not be counted twice. Assert.Equal(3, batch[genres[0]].EpisodeCount); Assert.Equal(1, batch[genres[0]].SeriesCount); // "loose": a tagged episode whose series carries no genre at all. Assert.Equal(1, batch[genres[1]].EpisodeCount); Assert.Equal(0, batch[genres[1]].SeriesCount); // "empty": tags nothing. Assert.Equal(0, batch[genres[2]].EpisodeCount); } [Fact] public void GetItemCountsForNameItems_EpisodeAndItsSeriesTaggedDifferently_KeepsTheGenresApart() { var seriesId = Guid.NewGuid(); var episodeId = Guid.NewGuid(); var genreIds = new List(); using (var context = CreateDbContext()) { var values = new Dictionary(StringComparer.Ordinal); foreach (var name in new[] { "on-series", "on-episode" }) { var genreId = Guid.NewGuid(); genreIds.Add(genreId); var genre = CreateItem(genreId); genre.Type = "Genre"; genre.Name = name; genre.CleanName = name; context.BaseItems.Add(genre); var itemValue = new ItemValue { ItemValueId = Guid.NewGuid(), Type = ItemValueType.Genre, Value = name, CleanValue = name }; context.ItemValues.Add(itemValue); values[name] = itemValue.ItemValueId; } var series = CreateItem(seriesId); series.Type = "Series"; context.BaseItems.Add(series); context.BaseItems.Add(CreateEpisode(episodeId, seriesId)); context.SaveChanges(); Tag(context, seriesId, values["on-series"]); Tag(context, episodeId, values["on-episode"]); context.SaveChanges(); } var filter = new InternalItemsQuery(); BaseItemKind[] related = [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie]; var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genreIds, related, filter); // The episode rolls up into the genre on its series. Assert.Equal(1, batch[genreIds[0]].EpisodeCount); // Its own genre is carried by no series, so the episode stays a direct count there. Keyed // on the series id alone the episode would be subtracted here and this would read 0. Assert.Equal(1, batch[genreIds[1]].EpisodeCount); Assert.Equal(0, batch[genreIds[1]].SeriesCount); foreach (var genreId in genreIds) { var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); Assert.Equal(single.ItemCount, batch[genreId].ItemCount); } } [Fact] public void GetItemCountsForNameItems_TwoNameItemsSharingACleanName_BothGetTheCounts() { // Distinct rows cleaning down to one name are what the batch keys on; the unique index // permits them, so two genre items can legitimately share a clean name. var firstId = Guid.NewGuid(); var secondId = Guid.NewGuid(); var movieId = Guid.NewGuid(); using (var context = CreateDbContext()) { foreach (var (id, name) in new[] { (firstId, "Sci-Fi"), (secondId, "SCI-FI") }) { var genre = CreateItem(id); genre.Type = "Genre"; genre.Name = name; genre.CleanName = "sci-fi"; context.BaseItems.Add(genre); } var movie = CreateItem(movieId); movie.Type = "Movie"; movie.IsFolder = false; context.BaseItems.Add(movie); context.SaveChanges(); foreach (var name in new[] { "Sci-Fi", "SCI-FI" }) { var itemValue = new ItemValue { ItemValueId = Guid.NewGuid(), Type = ItemValueType.Genre, Value = name, CleanValue = "sci-fi" }; context.ItemValues.Add(itemValue); context.SaveChanges(); Tag(context, movieId, itemValue.ItemValueId); } context.SaveChanges(); } var filter = new InternalItemsQuery(); BaseItemKind[] related = [BaseItemKind.Movie]; var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, [firstId, secondId], related, filter); // One movie, reached through two value rows: counted once for each genre item, not twice. Assert.Equal(1, batch[firstId].MovieCount); Assert.Equal(1, batch[secondId].MovieCount); foreach (var genreId in new[] { firstId, secondId }) { var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); Assert.Equal(single.MovieCount, batch[genreId].MovieCount); } } /// /// Seeds three genres: one tagging a series whose episodes roll up (one of them tagged too) /// plus a loose episode, one tagging only an episode of an untagged series, and one tagging /// nothing. /// /// The ids of the seeded genres, in that order. private List SeedGenresTaggingSeriesAndEpisodes() { var genreIds = new List(); using var context = CreateDbContext(); var values = new Dictionary(StringComparer.Ordinal); foreach (var name in new[] { "rolled", "loose", "empty" }) { var genreId = Guid.NewGuid(); genreIds.Add(genreId); var genre = CreateItem(genreId); genre.Type = "Genre"; genre.Name = name; genre.CleanName = name; context.BaseItems.Add(genre); var itemValue = new ItemValue { ItemValueId = Guid.NewGuid(), Type = ItemValueType.Genre, Value = name, CleanValue = name }; context.ItemValues.Add(itemValue); values[name] = itemValue.ItemValueId; } context.SaveChanges(); // A series tagged "rolled" holding two episodes; the second carries "rolled" itself, so the // roll-up and the direct tag both see it. var taggedSeriesId = Guid.NewGuid(); var taggedSeries = CreateItem(taggedSeriesId); taggedSeries.Type = "Series"; context.BaseItems.Add(taggedSeries); var episodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); var taggedEpisodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); context.BaseItems.AddRange(episodeOfTaggedSeries, taggedEpisodeOfTaggedSeries); // An untagged series whose episode carries a genre on its own. var untaggedSeriesId = Guid.NewGuid(); var untaggedSeries = CreateItem(untaggedSeriesId); untaggedSeries.Type = "Series"; context.BaseItems.Add(untaggedSeries); var looseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); var rolledLooseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); context.BaseItems.AddRange(looseEpisode, rolledLooseEpisode); var movieId = Guid.NewGuid(); var movie = CreateItem(movieId); movie.Type = "Movie"; movie.IsFolder = false; context.BaseItems.Add(movie); context.SaveChanges(); Tag(context, taggedSeriesId, values["rolled"]); Tag(context, taggedEpisodeOfTaggedSeries.Id, values["rolled"]); Tag(context, rolledLooseEpisode.Id, values["rolled"]); Tag(context, looseEpisode.Id, values["loose"]); Tag(context, movieId, values["rolled"]); context.SaveChanges(); return genreIds; } private static void Tag(JellyfinDbContext context, Guid itemId, Guid itemValueId) { context.ItemValuesMap.Add(new ItemValueMap { ItemId = itemId, ItemValueId = itemValueId, Item = null!, ItemValue = null! }); } private static BaseItemEntity CreateEpisode(Guid id, Guid seriesId) { return new BaseItemEntity { Id = id, Type = "Episode", IsFolder = false, IsVirtualItem = false, ParentId = seriesId, SeriesId = seriesId }; } /// /// Seeds four genres tagging three, two, one and no movies, in that order. /// /// The ids of the seeded genres. private List SeedGenres() { var genreIds = new List(); using var context = CreateDbContext(); for (var i = 0; i < 4; i++) { var name = "genre-" + i.ToString(CultureInfo.InvariantCulture); var genreId = Guid.NewGuid(); genreIds.Add(genreId); var genre = CreateItem(genreId); genre.Type = "Genre"; genre.Name = name; genre.CleanName = name; context.BaseItems.Add(genre); var itemValue = new ItemValue { ItemValueId = Guid.NewGuid(), Type = ItemValueType.Genre, Value = name, CleanValue = name }; context.ItemValues.Add(itemValue); context.SaveChanges(); // 3 movies for the first genre, 2 for the second, 1 for the third, none for the last. for (var m = 0; m < 3 - i; m++) { var movieId = Guid.NewGuid(); var movie = CreateItem(movieId); movie.Type = "Movie"; movie.IsFolder = false; context.BaseItems.Add(movie); context.SaveChanges(); context.ItemValuesMap.Add(new ItemValueMap { ItemId = movieId, ItemValueId = itemValue.ItemValueId, Item = null!, ItemValue = null! }); } context.SaveChanges(); } return genreIds; } 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.Instance, new SqliteDatabaseProvider( _applicationPaths, NullLogger.Instance), new NoLockBehavior(NullLogger.Instance)); } }