aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-09-07 18:39:59 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-09-07 18:39:59 +0200
commit2644163f57a6690366c3106faa392afb72b1e55a (patch)
tree177b4fc8cdccfc6c7e07f69c45c492c9c999daf5
parent59cd5f983bc30a468481543e2cc52575cfe3d818 (diff)
Count distinct items for byName ItemCounts and batch every kind
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs35
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs55
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs96
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs427
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs5
-rw-r--r--MediaBrowser.Controller/Persistence/IItemCountService.cs4
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs215
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs609
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>