diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-09-07 18:39:59 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-09-07 18:39:59 +0200 |
| commit | 2644163f57a6690366c3106faa392afb72b1e55a (patch) | |
| tree | 177b4fc8cdccfc6c7e07f69c45c492c9c999daf5 | |
| parent | 59cd5f983bc30a468481543e2cc52575cfe3d818 (diff) | |
Count distinct items for byName ItemCounts and batch every kind
8 files changed, 1185 insertions, 261 deletions
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index a2d3e14439..59e75691dc 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -185,6 +185,13 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); } + // Batch-fetch by-name item counts to avoid N+1 queries + Dictionary<Guid, ItemCounts>? itemCountsBatch = null; + if (options.ContainsField(ItemFields.ItemCounts)) + { + itemCountsBatch = GetItemCountsBatch(accessibleItems, user); + } + // Batch-fetch child counts for all folders to avoid N+1 queries Dictionary<Guid, int>? childCountBatch = null; if (options.ContainsField(ItemFields.ChildCount)) @@ -293,7 +300,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ItemCounts)) { - SetItemByNameInfo(dto, user); + SetItemByNameInfo(dto, user, itemCountsBatch); } returnItems[index] = dto; @@ -518,14 +525,36 @@ namespace Emby.Server.Implementations.Dto return dto; } - private void SetItemByNameInfo(BaseItemDto dto, User? user) + private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user) + { + var result = new Dictionary<Guid, ItemCounts>(); + + foreach (var group in items.GroupBy(item => item.GetBaseItemKind())) + { + if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds)) + { + continue; + } + + var ids = group.Select(item => item.Id).ToArray(); + foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user)) + { + result[id] = counts; + } + } + + return result; + } + + private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null) { if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds)) { return; } - var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); + var counts = prefetchedCounts?.GetValueOrDefault(dto.Id) + ?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); dto.AlbumCount = counts.AlbumCount; dto.ArtistCount = counts.ArtistCount; diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 70e4ca3b1d..cdc8744642 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -319,14 +319,7 @@ public sealed partial class BaseItemRepository .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo]; - var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) // Instead, start from ItemValueMaps and join with BaseItems. @@ -335,9 +328,9 @@ public sealed partial class BaseItemRepository scopedItems, ivm => ivm.ItemId, e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId }) + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId, e.Id }) .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId }) - .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() }) + .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Select(x => x.Id).Distinct().Count() }) .ToList(); // Only studios and genres pass down from a series to its episodes; an artist credit does not. @@ -359,46 +352,10 @@ public sealed partial class BaseItemRepository foreach (var group in rawCounts.GroupBy(x => x.CleanName)) { - var counts = new ItemCounts(); - foreach (var row in group) - { - if (row.Type == seriesTypeName) - { - counts.SeriesCount += row.Count; - } - else if (row.Type == movieTypeName) - { - counts.MovieCount += row.Count; - } - else if (row.Type == musicAlbumTypeName) - { - counts.AlbumCount += row.Count; - } - else if (row.Type == musicArtistTypeName) - { - counts.ArtistCount += row.Count; - } - else if (row.Type == musicVideoTypeName) - { - counts.MusicVideoCount += row.Count; - } - else if (row.Type == programTypeName) - { - counts.ProgramCount += row.Count; - } - else if (row.Type == audioTypeName) - { - counts.SongCount += row.Count; - } - else if (row.Type == trailerTypeName) - { - counts.TrailerCount += row.Count; - } - } + var counts = ItemCountBuilder.Build(_itemTypeLookup, group.Select(row => (row.Type, row.Count))); // Episodes are counted separately: the value is usually only written on the series. - counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key); - counts.ItemCount = counts.TotalItemCount(); + ItemCountBuilder.SetEpisodeCount(counts, episodeCounts.GetValueOrDefault(group.Key)); countsByCleanName[group.Key] = counts; } @@ -407,7 +364,9 @@ public sealed partial class BaseItemRepository { if (!countsByCleanName.ContainsKey(cleanName)) { - countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount }; + var counts = new ItemCounts(); + ItemCountBuilder.SetEpisodeCount(counts, episodeCount); + countsByCleanName[cleanName] = counts; } } diff --git a/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs new file mode 100644 index 0000000000..0f8a1b9dfe --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Turns per-type counts into an <see cref="ItemCounts"/>. +/// </summary> +internal static class ItemCountBuilder +{ + /// <summary> + /// Builds the counts of one by-name item. + /// </summary> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="counts">The counted items, by type name. A type may repeat.</param> + /// <returns>The counts.</returns> + public static ItemCounts Build(IItemTypeLookup itemTypeLookup, IEnumerable<(string Type, int Count)> counts) + { + ArgumentNullException.ThrowIfNull(itemTypeLookup); + ArgumentNullException.ThrowIfNull(counts); + + var lookup = itemTypeLookup.BaseItemKindNames; + var result = new ItemCounts(); + + foreach (var (type, count) in counts) + { + // Accumulated rather than assigned: a caller may group by something finer than the + // type and hand the same type over more than once. + if (string.Equals(type, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) + { + result.AlbumCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) + { + result.ArtistCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) + { + result.EpisodeCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) + { + result.MovieCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) + { + result.MusicVideoCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) + { + result.ProgramCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + { + result.SeriesCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + { + result.SongCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + { + result.TrailerCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + { + result.BoxSetCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + { + result.BookCount += count; + } + } + + result.ItemCount = result.TotalItemCount(); + + return result; + } + + /// <summary> + /// Replaces the episode count, which both by-name paths decide separately from the other + /// types because a genre or studio is usually written on the series rather than its episodes. + /// </summary> + /// <param name="counts">The counts to update.</param> + /// <param name="episodeCount">The episode count.</param> + public static void SetEpisodeCount(ItemCounts counts, int episodeCount) + { + ArgumentNullException.ThrowIfNull(counts); + + counts.EpisodeCount = episodeCount; + counts.ItemCount = counts.TotalItemCount(); + } +} diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 8b0aac2836..57705cdf11 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -126,279 +126,286 @@ public class ItemCountService : IItemCountService /// <inheritdoc /> public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) { - using var context = _dbProvider.CreateDbContext(); - - var item = context.BaseItems.AsNoTracking() - .Where(e => e.Id == id) - .Select(e => new { e.Name, e.CleanName }) - .FirstOrDefault(); + return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id]; + } - if (item is null) + private static ItemValueType[] GetItemValueTypes(BaseItemKind kind) + => kind switch { - return new ItemCounts(); - } + BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist], + BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre], + BaseItemKind.Studio => [ItemValueType.Studios], + _ => [] + }; - IQueryable<BaseItemEntity> baseQuery; - switch (kind) - { - case BaseItemKind.Person: - baseQuery = ItemsById(context, context.PeopleBaseItemMap - .AsNoTracking() - .Where(m => m.People.Name == item.Name) - .Select(m => m.ItemId)); - break; - case BaseItemKind.MusicArtist: - baseQuery = ItemsById(context, context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist)) - .Select(ivm => ivm.ItemId)); - break; - case BaseItemKind.Genre: - case BaseItemKind.MusicGenre: - baseQuery = ItemsById(context, context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && ivm.ItemValue.Type == ItemValueType.Genre) - .Select(ivm => ivm.ItemId)); - break; - case BaseItemKind.Studio: - baseQuery = ItemsById(context, context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && ivm.ItemValue.Type == ItemValueType.Studios) - .Select(ivm => ivm.ItemId)); - break; - case BaseItemKind.Year: - if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) - { - baseQuery = context.BaseItems - .AsNoTracking() - .Where(e => e.ProductionYear == year); - } - else - { - return new ItemCounts(); - } + /// <inheritdoc /> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(relatedItemKinds); + ArgumentNullException.ThrowIfNull(accessFilter); - break; - default: - return new ItemCounts(); + var result = new Dictionary<Guid, ItemCounts>(); + if (ids.Count == 0) + { + return result; } - var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray(); - baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type)); - - baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter); + using var context = _dbProvider.CreateDbContext(); - var counts = baseQuery - .GroupBy(x => x.Type) - .Select(x => new { x.Key, Count = x.Count() }) + var idsArray = ids as Guid[] ?? ids.ToArray(); + var nameItems = context.BaseItems.AsNoTracking() + .WhereOneOrMany(idsArray, e => e.Id) + .Select(e => new NameItem(e.Id, e.Name, e.CleanName)) .ToArray(); - var result = BuildItemCounts(counts.Select(c => (c.Key, c.Count))); - var totalCount = result.ItemCount; + foreach (var id in ids) + { + result[id] = new ItemCounts(); + } - if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre - && relatedItemKinds.Contains(BaseItemKind.Episode) - && relatedItemKinds.Contains(BaseItemKind.Series)) + if (nameItems.Length == 0) { - var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount); - totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount; - result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount; + return result; } - result.ItemCount = totalCount; + var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray(); + var related = _queryHelpers.ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)), + accessFilter); + + var valueTypes = GetItemValueTypes(kind); + if (valueTypes.Length > 0) + { + CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result); + } + else if (kind == BaseItemKind.Person) + { + CountByPersonName(context, related, nameItems, result); + } + else if (kind == BaseItemKind.Year) + { + CountByProductionYear(related, nameItems, result); + } return result; } - private int CountEpisodesOfTaggedSeries( + private void CountByItemValue( JellyfinDbContext context, - IQueryable<BaseItemEntity> taggedItems, - InternalItemsQuery accessFilter, - out int unrelatedEpisodeCount) + IQueryable<BaseItemEntity> related, + BaseItemKind kind, + BaseItemKind[] relatedItemKinds, + ItemValueType[] valueTypes, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) { - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - - var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id); - unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName - && (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value))); - - // Materialised so the episode count drives off IX_BaseItems_SeriesId. - var seriesIds = taggedItems - .Where(e => e.Type == seriesTypeName) - .Select(e => e.Id) + var cleanNames = nameItems + .Select(n => n.CleanName) + .OfType<string>() + .Distinct(StringComparer.Ordinal) .ToArray(); - if (seriesIds.Length == 0) + if (cleanNames.Length == 0) { - return 0; + return; } - var episodes = context.BaseItems.AsNoTracking() - .Where(e => e.Type == episodeTypeName && e.SeriesId != null) - .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value); + var grouped = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue) + .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id }) + .GroupBy(x => new { x.CleanValue, x.Type }) + .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); - return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count(); - } + var byCleanName = grouped + .GroupBy(g => g.CleanValue, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); - private ItemCounts BuildItemCounts(IEnumerable<(string Key, int Count)> counts) - { - var lookup = _itemTypeLookup.BaseItemKindNames; - var result = new ItemCounts(); - var totalCount = 0; + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds) + && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal)) + ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames) + : null; - foreach (var count in counts) + foreach (var nameItem in nameItems) { - totalCount += count.Count; - - if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) - { - result.AlbumCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) - { - result.ArtistCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) - { - result.EpisodeCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) - { - result.MovieCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) - { - result.MusicVideoCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) - { - result.ProgramCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) - { - result.SeriesCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts)) { - result.SongCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) - { - result.TrailerCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) - { - result.BoxSetCount = count.Count; + continue; } - else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + + var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts); + + if (episodeRollUp is not null) { - result.BookCount = count.Count; - } - } + var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName); - result.ItemCount = totalCount; + // Episodes of a tagged series count towards it even when untagged themselves, and + // a tagged episode of a tagged series must not be counted a second time. + var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries; + ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount); + } - return result; + result[nameItem.Id] = itemCounts; + } } - private static ItemValueType[] GetItemValueTypes(BaseItemKind kind) - => kind switch - { - BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist], - BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre], - BaseItemKind.Studio => [ItemValueType.Studios], - _ => [] - }; - - /// <inheritdoc /> - public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) + private void CountByPersonName( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) { - ArgumentNullException.ThrowIfNull(ids); - ArgumentNullException.ThrowIfNull(relatedItemKinds); + var names = nameItems + .Select(n => n.Name) + .OfType<string>() + .Distinct(StringComparer.Ordinal) + .ToArray(); - var result = new Dictionary<Guid, ItemCounts>(); - if (ids.Count == 0) + if (names.Length == 0) { - return result; + return; } - // Only the kinds keyed by a cleaned item value can be grouped for every id in one query. - // Anything else, and the listing that rolls the episodes of a tagged series up into it, - // keeps the single item path, so a batch can never report a different number than it. - var valueTypes = GetItemValueTypes(kind); - var rollsUpEpisodes = kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre - && relatedItemKinds.Contains(BaseItemKind.Episode) - && relatedItemKinds.Contains(BaseItemKind.Series); + var grouped = context.PeopleBaseItemMap.AsNoTracking() + .WhereOneOrMany(names, m => m.People.Name) + .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id }) + .GroupBy(x => new { x.Name, x.Type }) + // A person can be credited on one item more than once, in different roles. + .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result); + } - if (valueTypes.Length == 0 || rollsUpEpisodes) + private void CountByProductionYear( + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var years = new List<int>(); + foreach (var nameItem in nameItems) { - foreach (var id in ids) + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && !years.Contains(year)) { - result[id] = GetItemCountsForNameItem(kind, id, relatedItemKinds, accessFilter); + years.Add(year); } - - return result; } - using var context = _dbProvider.CreateDbContext(); + if (years.Count == 0) + { + return; + } - var nameItems = context.BaseItems.AsNoTracking() - .Where(e => ids.Contains(e.Id)) - .Select(e => new { e.Id, e.CleanName }) + // No join, so no row can be reached twice and a plain count is the distinct count. + var grouped = related + .Where(e => e.ProductionYear != null) + .WhereOneOrMany(years, e => e.ProductionYear!.Value) + .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type }) + .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() }) .ToArray(); - foreach (var id in ids) + var byYear = grouped + .GroupBy(g => g.Year) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray()); + + foreach (var nameItem in nameItems) { - result[id] = new ItemCounts(); + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && byYear.TryGetValue(year, out var counts)) + { + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); + } } + } - var cleanNames = nameItems - .Select(n => n.CleanName) - .Where(n => n is not null) - .Distinct(StringComparer.Ordinal) - .ToArray(); + private void ApplyGroupedCounts( + NameItem[] nameItems, + Func<NameItem, string?> keySelector, + IEnumerable<(string Key, string Type, int Count)> grouped, + Dictionary<Guid, ItemCounts> result) + { + var byKey = grouped + .GroupBy(g => g.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); - if (cleanNames.Length == 0) + foreach (var nameItem in nameItems) { - return result; + var key = keySelector(nameItem); + if (key is not null && byKey.TryGetValue(key, out var counts)) + { + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); + } } + } - var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray(); + private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds) + => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre + && relatedItemKinds.Contains(BaseItemKind.Episode) + && relatedItemKinds.Contains(BaseItemKind.Series); - var related = _queryHelpers.ApplyAccessFiltering( - context, - context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)), - accessFilter); + private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeriesByCleanName( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + ItemValueType[] valueTypes, + string[] cleanNames) + { + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var grouped = context.ItemValuesMap.AsNoTracking() - .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type) && cleanNames.Contains(ivm.ItemValue.CleanValue)) - .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type }) - .GroupBy(x => new { x.CleanValue, x.Type }) - .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Count() }) + var taggedValues = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); + + // The series carrying each clean name. Distinct, because one item can be mapped to the + // same clean name once per value type. + var taggedSeries = taggedValues + .Join( + related.Where(e => e.Type == seriesTypeName), + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .Distinct(); + + var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null); + + var episodesOfTaggedSeries = taggedSeries + .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) .ToArray(); - var byCleanName = grouped - .GroupBy(g => g.CleanValue, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); + // Episodes that carry the clean name themselves *and* belong to a series carrying it. The + // roll-up already counts those, so they have to come off the directly tagged ones. + var taggedEpisodesOfTaggedSeries = taggedValues + .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId }) + .Join( + taggedSeries, + e => new { e.CleanValue, SeriesId = e.SeriesId!.Value }, + s => new { s.CleanValue, s.SeriesId }, + (e, s) => new { e.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); - foreach (var nameItem in nameItems) + var taggedLookup = taggedEpisodesOfTaggedSeries + .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal); + + // Every clean name in taggedLookup came from an episode of a tagged series, so it always + // has a row in episodesOfTaggedSeries too - no second merge pass is needed. + var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringComparer.Ordinal); + foreach (var entry in episodesOfTaggedSeries) { - if (nameItem.CleanName is not null && byCleanName.TryGetValue(nameItem.CleanName, out var counts)) - { - result[nameItem.Id] = BuildItemCounts(counts); - } + result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue)); } return result; } - private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds) - => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id)); - /// <inheritdoc/> public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { @@ -724,4 +731,12 @@ public class ItemCountService : IItemCountService return result is null ? (0, 0) : (result.Played, result.Total); } + + /// <summary> + /// A by-name item, reduced to the three columns the counting keys off. + /// </summary> + /// <param name="Id">The id of the by-name item.</param> + /// <param name="Name">The name of the by-name item.</param> + /// <param name="CleanName">The cleaned name of the by-name item.</param> + private sealed record NameItem(Guid Id, string? Name, string? CleanName); } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 382559384a..ac6df54949 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -760,8 +760,9 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user); /// <summary> - /// Gets item counts for several "by-name" items of the same kind in one query, instead of - /// one query per item. + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the + /// whole batch; the rest fall back to one query per item. /// </summary> /// <param name="kind">The kind of the name items.</param> /// <param name="ids">The IDs of the name items.</param> diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs index 20b5cbe603..14c422cb60 100644 --- a/MediaBrowser.Controller/Persistence/IItemCountService.cs +++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs @@ -37,7 +37,9 @@ public interface IItemCountService ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); /// <summary> - /// Gets item counts for several "by-name" items of the same kind in one query. + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the whole + /// batch; the rest fall back to one query per id. /// </summary> /// <param name="kind">The kind of the name items.</param> /// <param name="ids">The IDs of the name items.</param> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs new file mode 100644 index 0000000000..298340d1b0 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs @@ -0,0 +1,215 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Querying; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// The by-name listings count what a cleaned value is attached to by joining ItemValuesMap to +/// BaseItems. One item can reach the same clean value through more than one value row, so the +/// join has to be counted per distinct item; counting rows reports a multiple of the truth. +/// </summary> +public sealed class BaseItemRepositoryByNameItemCountsTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryByNameItemCountsTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + } + + [Fact] + public void GetAllArtists_AlbumCreditedAsArtistAndAlbumArtist_CountsTheAlbumOnce() + { + // GetAllArtists spans both credit types, so an album whose artist is also its album artist + // reaches the one clean value through two rows. + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.AlbumArtist); + + var result = _repository.GetAllArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + Assert.Equal(1, counts.ItemCount); + } + + [Fact] + public void GetAlbumArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + // The shape that actually reaches users: only (Type, Value) is unique, so two differently + // cased credits of one type both clean down to a single name and both map the album. + SeedArtistWithAlbum(ItemValueType.AlbumArtist, ItemValueType.AlbumArtist); + + var result = _repository.GetAlbumArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Fact] + public void GetArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.Artist); + + var result = _repository.GetArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Theory] + [InlineData(BaseItemKind.Book)] + [InlineData(BaseItemKind.BoxSet)] + public void GetGenres_TaggedBookOrBoxSet_CountsIt(BaseItemKind kind) + { + // The listing used to dispatch only nine of the eleven counted types, so a genre on a book + // or a box set read as zero in a list and as one on the genre's own page. + SeedGenreWith(kind); + + var result = _repository.GetGenres(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, kind == BaseItemKind.Book ? counts.BookCount : counts.BoxSetCount); + Assert.Equal(1, counts.ItemCount); + } + + /// <summary> + /// Seeds one genre carried by a single item of the given kind. + /// </summary> + /// <param name="kind">The kind of the tagged item.</param> + private void SeedGenreWith(BaseItemKind kind) + { + const string Name = "Reference"; + const string CleanName = "reference"; + + using var ctx = CreateDbContext(); + + var genreId = Guid.Parse("dddddddd-0000-0000-0000-000000000001"); + var taggedId = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = genreId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = genreId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var tagged = new BaseItemEntity + { + Id = taggedId, + Type = _itemTypeLookup.BaseItemKindNames[kind], + Name = "Tagged", + CleanName = "tagged", + PresentationUniqueKey = taggedId.ToString("N"), + IsFolder = false, + IsVirtualItem = false + }; + ctx.BaseItems.Add(tagged); + + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse("ffffffff-0000-0000-0000-000000000001"), + Type = ItemValueType.Genre, + Value = Name, + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = taggedId, + ItemValueId = itemValue.ItemValueId, + Item = tagged, + ItemValue = itemValue + }); + + ctx.SaveChanges(); + } + + private static InternalItemsQuery CreateCountingQuery() + { + return new InternalItemsQuery(new User("test", "auth", "reset")) + { + DtoOptions = new DtoOptions(true) { Fields = [ItemFields.ItemCounts] } + }; + } + + /// <summary> + /// Seeds one artist and a single album mapped to that artist's clean name through two value + /// rows of the given types. + /// </summary> + /// <param name="first">The type of the first value row.</param> + /// <param name="second">The type of the second value row.</param> + private void SeedArtistWithAlbum(ItemValueType first, ItemValueType second) + { + const string Name = "Tangerine Dream"; + const string CleanName = "tangerine dream"; + + using var ctx = CreateDbContext(); + + var artistId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + var albumId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var album = new BaseItemEntity + { + Id = albumId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum], + Name = "Phaedra", + CleanName = "phaedra", + PresentationUniqueKey = albumId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + ctx.BaseItems.Add(album); + + var types = new[] { first, second }; + for (var i = 0; i < types.Length; i++) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"), + Type = types[i], + // Distinct values, one clean name: exactly what the unique index permits. + Value = i == 0 ? Name : Name.ToUpperInvariant(), + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = album, + ItemValue = itemValue + }); + } + + ctx.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index d0e29e5142..ff683dc57a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -15,6 +15,7 @@ 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; @@ -28,6 +29,8 @@ public sealed class ItemCountServiceTests : IDisposable private readonly DbContextOptions<JellyfinDbContext> _dbOptions; private readonly IApplicationPaths _applicationPaths; private readonly ItemCountService _service; + private int _contextsCreated; + private List<string>? _capturedSql; public ItemCountServiceTests() { @@ -38,6 +41,7 @@ public sealed class ItemCountServiceTests : IDisposable _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() .UseSqlite(_connection) + .LogTo(CaptureStatement, LogLevel.Information) .Options; using (var context = CreateDbContext()) @@ -46,7 +50,11 @@ public sealed class ItemCountServiceTests : IDisposable } var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContext()).Returns(() => + { + _contextsCreated++; + return CreateDbContext(); + }); var queryHelpers = new Mock<IItemQueryHelpers>(); queryHelpers @@ -83,6 +91,14 @@ public sealed class ItemCountServiceTests : IDisposable _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() { @@ -395,6 +411,597 @@ public sealed class ItemCountServiceTests : IDisposable 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); + } + + /// <summary> + /// Seeds one artist and a single album tagged with it as both artist and album artist. + /// </summary> + /// <returns>The id of the seeded artist.</returns> + 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<string> CaptureSql(Action action) + { + _capturedSql = []; + try + { + action(); + return _capturedSql; + } + finally + { + _capturedSql = null; + } + } + + /// <summary> + /// Seeds the requested number of artists, each with a clean name of its own, one of which is + /// credited on a single album. + /// </summary> + /// <param name="count">The number of artists to seed.</param> + /// <param name="taggedArtistId">The id of the artist credited on an album.</param> + /// <returns>The ids of the seeded artists.</returns> + private List<Guid> SeedArtists(int count, out Guid taggedArtistId) + { + var ids = new List<Guid>(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); + } + } + } + + /// <summary> + /// Seeds two people and two years, the first of each on two movies and the second on one. + /// </summary> + /// <returns>The ids of the seeded people and years.</returns> + private (List<Guid> PersonIds, List<Guid> YearIds) SeedPeopleAndYears() + { + var personIds = new List<Guid>(); + var yearIds = new List<Guid>(); + + 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<Guid>(); + + using (var context = CreateDbContext()) + { + var values = new Dictionary<string, Guid>(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); + } + } + + /// <summary> + /// 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. + /// </summary> + /// <returns>The ids of the seeded genres, in that order.</returns> + private List<Guid> SeedGenresTaggingSeriesAndEpisodes() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + var values = new Dictionary<string, Guid>(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 + }; + } + /// <summary> /// Seeds four genres tagging three, two, one and no movies, in that order. /// </summary> |
