Expose optimized ItemCounts for byName items
This commit is contained in:
@@ -1801,6 +1801,18 @@ namespace Emby.Server.Implementations.Library
|
||||
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user)
|
||||
{
|
||||
var query = new InternalItemsQuery(user);
|
||||
if (user is not null)
|
||||
{
|
||||
AddUserToQuery(query, user);
|
||||
}
|
||||
|
||||
return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query);
|
||||
}
|
||||
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
return _countService.GetChildCountBatch(parentIds, user);
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
@@ -195,6 +196,56 @@ public class ItemCountService : IItemCountService
|
||||
.Select(x => new { x.Key, Count = x.Count() })
|
||||
.ToArray();
|
||||
|
||||
var result = BuildItemCounts(counts.Select(c => (c.Key, c.Count)));
|
||||
var totalCount = result.ItemCount;
|
||||
|
||||
if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Episode)
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Series))
|
||||
{
|
||||
var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
|
||||
totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
|
||||
result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
|
||||
}
|
||||
|
||||
result.ItemCount = totalCount;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private int CountEpisodesOfTaggedSeries(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> taggedItems,
|
||||
InternalItemsQuery accessFilter,
|
||||
out int unrelatedEpisodeCount)
|
||||
{
|
||||
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)
|
||||
.ToArray();
|
||||
|
||||
if (seriesIds.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var episodes = context.BaseItems.AsNoTracking()
|
||||
.Where(e => e.Type == episodeTypeName && e.SeriesId != null)
|
||||
.WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
|
||||
|
||||
return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
|
||||
}
|
||||
|
||||
private ItemCounts BuildItemCounts(IEnumerable<(string Key, int Count)> counts)
|
||||
{
|
||||
var lookup = _itemTypeLookup.BaseItemKindNames;
|
||||
var result = new ItemCounts();
|
||||
var totalCount = 0;
|
||||
@@ -249,49 +300,100 @@ public class ItemCountService : IItemCountService
|
||||
}
|
||||
}
|
||||
|
||||
if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Episode)
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Series))
|
||||
{
|
||||
var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
|
||||
totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
|
||||
result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
|
||||
}
|
||||
|
||||
result.ItemCount = totalCount;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private int CountEpisodesOfTaggedSeries(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> taggedItems,
|
||||
InternalItemsQuery accessFilter,
|
||||
out int unrelatedEpisodeCount)
|
||||
{
|
||||
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)
|
||||
.ToArray();
|
||||
|
||||
if (seriesIds.Length == 0)
|
||||
private static ItemValueType[] GetItemValueTypes(BaseItemKind kind)
|
||||
=> kind switch
|
||||
{
|
||||
return 0;
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ids);
|
||||
ArgumentNullException.ThrowIfNull(relatedItemKinds);
|
||||
|
||||
var result = new Dictionary<Guid, ItemCounts>();
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var episodes = context.BaseItems.AsNoTracking()
|
||||
.Where(e => e.Type == episodeTypeName && e.SeriesId != null)
|
||||
.WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
|
||||
// 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);
|
||||
|
||||
return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
|
||||
if (valueTypes.Length == 0 || rollsUpEpisodes)
|
||||
{
|
||||
foreach (var id in ids)
|
||||
{
|
||||
result[id] = GetItemCountsForNameItem(kind, id, relatedItemKinds, accessFilter);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var nameItems = context.BaseItems.AsNoTracking()
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.Select(e => new { e.Id, e.CleanName })
|
||||
.ToArray();
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
result[id] = new ItemCounts();
|
||||
}
|
||||
|
||||
var cleanNames = nameItems
|
||||
.Select(n => n.CleanName)
|
||||
.Where(n => n is not null)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (cleanNames.Length == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
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 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() })
|
||||
.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);
|
||||
|
||||
foreach (var nameItem in nameItems)
|
||||
{
|
||||
if (nameItem.CleanName is not null && byCleanName.TryGetValue(nameItem.CleanName, out var counts))
|
||||
{
|
||||
result[nameItem.Id] = BuildItemCounts(counts);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
|
||||
|
||||
@@ -759,6 +759,17 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <returns>The item counts grouped by type.</returns>
|
||||
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.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of the name items.</param>
|
||||
/// <param name="ids">The IDs of the name items.</param>
|
||||
/// <param name="relatedItemKinds">The item kinds to count.</param>
|
||||
/// <param name="user">The user for access filtering.</param>
|
||||
/// <returns>The item counts of each requested id.</returns>
|
||||
Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user);
|
||||
|
||||
/// <summary>
|
||||
/// Batch-fetches child counts for multiple parent folders.
|
||||
/// Returns the count of immediate children (non-recursive) for each parent.
|
||||
|
||||
@@ -36,6 +36,16 @@ public interface IItemCountService
|
||||
/// <returns>The item counts grouped by type.</returns>
|
||||
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.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of the name items.</param>
|
||||
/// <param name="ids">The IDs of the name items.</param>
|
||||
/// <param name="relatedItemKinds">The item kinds to count.</param>
|
||||
/// <param name="accessFilter">A pre-configured query with user access filtering settings.</param>
|
||||
/// <returns>The item counts of each requested id.</returns>
|
||||
Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of played items that are descendants of the specified ancestor.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
@@ -53,9 +56,25 @@ public sealed class ItemCountServiceTests : IDisposable
|
||||
It.IsAny<InternalItemsQuery>()))
|
||||
.Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query);
|
||||
|
||||
var typeLookup = new Mock<IItemTypeLookup>();
|
||||
typeLookup.Setup(l => l.BaseItemKindNames).Returns(new Dictionary<BaseItemKind, string>
|
||||
{
|
||||
[BaseItemKind.Movie] = "Movie",
|
||||
[BaseItemKind.Series] = "Series",
|
||||
[BaseItemKind.Episode] = "Episode",
|
||||
[BaseItemKind.MusicAlbum] = "MusicAlbum",
|
||||
[BaseItemKind.MusicArtist] = "MusicArtist",
|
||||
[BaseItemKind.MusicVideo] = "MusicVideo",
|
||||
[BaseItemKind.Audio] = "Audio",
|
||||
[BaseItemKind.Trailer] = "Trailer",
|
||||
[BaseItemKind.BoxSet] = "BoxSet",
|
||||
[BaseItemKind.Book] = "Book",
|
||||
[BaseItemKind.LiveTvProgram] = "LiveTvProgram"
|
||||
});
|
||||
|
||||
_service = new ItemCountService(
|
||||
factory.Object,
|
||||
new Mock<IItemTypeLookup>().Object,
|
||||
typeLookup.Object,
|
||||
queryHelpers.Object);
|
||||
}
|
||||
|
||||
@@ -335,6 +354,104 @@ public sealed class ItemCountServiceTests : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetItemCountsForNameItems_MatchesCountingEachNameItemOnItsOwn()
|
||||
{
|
||||
// Three genres tagging a different number of movies each, plus one tagging nothing.
|
||||
var genres = SeedGenres();
|
||||
|
||||
var filter = new InternalItemsQuery();
|
||||
BaseItemKind[] related = [BaseItemKind.Movie, BaseItemKind.Series];
|
||||
|
||||
var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter);
|
||||
|
||||
// Every requested id is answered, so a caller can index the result without checking.
|
||||
Assert.Equal(genres.Count, batch.Count);
|
||||
|
||||
foreach (var genreId in genres)
|
||||
{
|
||||
var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter);
|
||||
|
||||
Assert.Equal(single.MovieCount, batch[genreId].MovieCount);
|
||||
Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount);
|
||||
Assert.Equal(single.ItemCount, batch[genreId].ItemCount);
|
||||
}
|
||||
|
||||
// And the counts are the seeded ones rather than all zero, which would match trivially.
|
||||
Assert.Equal([3, 2, 1, 0], genres.Select(g => batch[g].MovieCount).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetItemCountsForNameItems_UnknownId_CountsZero()
|
||||
{
|
||||
var unknown = Guid.NewGuid();
|
||||
|
||||
var batch = _service.GetItemCountsForNameItems(
|
||||
BaseItemKind.Genre,
|
||||
[unknown],
|
||||
[BaseItemKind.Movie],
|
||||
new InternalItemsQuery());
|
||||
|
||||
Assert.Equal(0, batch[unknown].ItemCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds four genres tagging three, two, one and no movies, in that order.
|
||||
/// </summary>
|
||||
/// <returns>The ids of the seeded genres.</returns>
|
||||
private List<Guid> SeedGenres()
|
||||
{
|
||||
var genreIds = new List<Guid>();
|
||||
|
||||
using var context = CreateDbContext();
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var name = "genre-" + i.ToString(CultureInfo.InvariantCulture);
|
||||
var genreId = Guid.NewGuid();
|
||||
genreIds.Add(genreId);
|
||||
|
||||
var genre = CreateItem(genreId);
|
||||
genre.Type = "Genre";
|
||||
genre.Name = name;
|
||||
genre.CleanName = name;
|
||||
context.BaseItems.Add(genre);
|
||||
|
||||
var itemValue = new ItemValue
|
||||
{
|
||||
ItemValueId = Guid.NewGuid(),
|
||||
Type = ItemValueType.Genre,
|
||||
Value = name,
|
||||
CleanValue = name
|
||||
};
|
||||
context.ItemValues.Add(itemValue);
|
||||
context.SaveChanges();
|
||||
|
||||
// 3 movies for the first genre, 2 for the second, 1 for the third, none for the last.
|
||||
for (var m = 0; m < 3 - i; m++)
|
||||
{
|
||||
var movieId = Guid.NewGuid();
|
||||
var movie = CreateItem(movieId);
|
||||
movie.Type = "Movie";
|
||||
movie.IsFolder = false;
|
||||
context.BaseItems.Add(movie);
|
||||
context.SaveChanges();
|
||||
|
||||
context.ItemValuesMap.Add(new ItemValueMap
|
||||
{
|
||||
ItemId = movieId,
|
||||
ItemValueId = itemValue.ItemValueId,
|
||||
Item = null!,
|
||||
ItemValue = null!
|
||||
});
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
return genreIds;
|
||||
}
|
||||
|
||||
private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
|
||||
{
|
||||
return new BaseItemEntity
|
||||
|
||||
Reference in New Issue
Block a user