Merge pull request #17821 from Shadowghost/more-counts
Expose optimized ItemCounts for byName items
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -125,178 +126,286 @@ public class ItemCountService : IItemCountService
|
||||
/// <inheritdoc />
|
||||
public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id];
|
||||
}
|
||||
|
||||
var item = context.BaseItems.AsNoTracking()
|
||||
.Where(e => e.Id == id)
|
||||
.Select(e => new { e.Name, e.CleanName })
|
||||
.FirstOrDefault();
|
||||
|
||||
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],
|
||||
_ => []
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ids);
|
||||
ArgumentNullException.ThrowIfNull(relatedItemKinds);
|
||||
ArgumentNullException.ThrowIfNull(accessFilter);
|
||||
|
||||
var result = new Dictionary<Guid, ItemCounts>();
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
break;
|
||||
default:
|
||||
return new ItemCounts();
|
||||
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();
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
result[id] = new ItemCounts();
|
||||
}
|
||||
|
||||
if (nameItems.Length == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray();
|
||||
baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type));
|
||||
var related = _queryHelpers.ApplyAccessFiltering(
|
||||
context,
|
||||
context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)),
|
||||
accessFilter);
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter);
|
||||
|
||||
var counts = baseQuery
|
||||
.GroupBy(x => x.Type)
|
||||
.Select(x => new { x.Key, Count = x.Count() })
|
||||
.ToArray();
|
||||
|
||||
var lookup = _itemTypeLookup.BaseItemKindNames;
|
||||
var result = new ItemCounts();
|
||||
var totalCount = 0;
|
||||
|
||||
foreach (var count in counts)
|
||||
var valueTypes = GetItemValueTypes(kind);
|
||||
if (valueTypes.Length > 0)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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;
|
||||
}
|
||||
else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal))
|
||||
{
|
||||
result.BookCount = count.Count;
|
||||
}
|
||||
CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result);
|
||||
}
|
||||
|
||||
if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Episode)
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Series))
|
||||
else if (kind == BaseItemKind.Person)
|
||||
{
|
||||
var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
|
||||
totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
|
||||
result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
|
||||
CountByPersonName(context, related, nameItems, result);
|
||||
}
|
||||
else if (kind == BaseItemKind.Year)
|
||||
{
|
||||
CountByProductionYear(related, nameItems, result);
|
||||
}
|
||||
|
||||
result.ItemCount = totalCount;
|
||||
|
||||
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 cleanNames = nameItems
|
||||
.Select(n => n.CleanName)
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (cleanNames.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
var byCleanName = grouped
|
||||
.GroupBy(g => g.CleanValue, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
|
||||
|
||||
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 nameItem in nameItems)
|
||||
{
|
||||
if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts);
|
||||
|
||||
if (episodeRollUp is not null)
|
||||
{
|
||||
var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
result[nameItem.Id] = itemCounts;
|
||||
}
|
||||
}
|
||||
|
||||
private void CountByPersonName(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> related,
|
||||
NameItem[] nameItems,
|
||||
Dictionary<Guid, ItemCounts> result)
|
||||
{
|
||||
var names = nameItems
|
||||
.Select(n => n.Name)
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (names.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private void CountByProductionYear(
|
||||
IQueryable<BaseItemEntity> related,
|
||||
NameItem[] nameItems,
|
||||
Dictionary<Guid, ItemCounts> result)
|
||||
{
|
||||
var years = new List<int>();
|
||||
foreach (var nameItem in nameItems)
|
||||
{
|
||||
if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
|
||||
&& !years.Contains(year))
|
||||
{
|
||||
years.Add(year);
|
||||
}
|
||||
}
|
||||
|
||||
if (years.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
foreach (var nameItem in nameItems)
|
||||
{
|
||||
var key = keySelector(nameItem);
|
||||
if (key is not null && byKey.TryGetValue(key, out var counts))
|
||||
{
|
||||
result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 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)));
|
||||
var taggedValues = context.ItemValuesMap.AsNoTracking()
|
||||
.Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
|
||||
.WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
|
||||
|
||||
// Materialised so the episode count drives off IX_BaseItems_SeriesId.
|
||||
var seriesIds = taggedItems
|
||||
.Where(e => e.Type == seriesTypeName)
|
||||
.Select(e => e.Id)
|
||||
// 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();
|
||||
|
||||
if (seriesIds.Length == 0)
|
||||
// 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();
|
||||
|
||||
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)
|
||||
{
|
||||
return 0;
|
||||
result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue));
|
||||
}
|
||||
|
||||
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();
|
||||
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)
|
||||
{
|
||||
@@ -622,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);
|
||||
}
|
||||
|
||||
@@ -759,6 +759,18 @@ 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. 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>
|
||||
/// <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,18 @@ 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. 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>
|
||||
/// <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>
|
||||
|
||||
+215
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -12,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;
|
||||
@@ -25,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()
|
||||
{
|
||||
@@ -35,6 +41,7 @@ public sealed class ItemCountServiceTests : IDisposable
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.LogTo(CaptureStatement, LogLevel.Information)
|
||||
.Options;
|
||||
|
||||
using (var context = CreateDbContext())
|
||||
@@ -43,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
|
||||
@@ -53,9 +64,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);
|
||||
}
|
||||
|
||||
@@ -64,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()
|
||||
{
|
||||
@@ -335,6 +370,695 @@ 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);
|
||||
}
|
||||
|
||||
[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>
|
||||
/// <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