Fix series merging leaking across libraries and under-counting merged children

This commit is contained in:
Shadowghost
2026-08-21 22:48:47 +02:00
parent 9fa0533506
commit 8e80677bdd
5 changed files with 402 additions and 28 deletions
@@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}
/// <inheritdoc/>
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}
@@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
{
var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds);
var baseQuery = dbContext.BaseItems
.AsNoTracking()
.Where(b => descendantIds.Contains(b.Id))
.Where(DescendantQueryHelper.IsCountableLeaf);
return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
}
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
@@ -330,9 +345,17 @@ public class ItemCountService : IItemCountService
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
{
if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
{
result[parentId] = mergedCount;
continue;
}
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
@@ -342,6 +365,50 @@ public class ItemCountService : IItemCountService
return result;
}
private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
.ToArray();
if (mergedGroups.Length == 0)
{
return [];
}
// Only merged folders.
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
var children = dbContext.BaseItems
.AsNoTracking()
.Where(b => b.ParentId.HasValue)
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
.ToArray()
.GroupBy(b => b.ParentId)
.ToDictionary(
g => g.Key,
g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
? b.Id.ToString("N", CultureInfo.InvariantCulture)
: b.PresentationUniqueKey).ToArray());
var result = new Dictionary<Guid, int>();
foreach (var (parentId, members) in mergedGroups)
{
var childKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var member in members)
{
if (children.TryGetValue(member, out var keys))
{
childKeys.UnionWith(keys);
}
}
result[parentId] = childKeys.Count;
}
return result;
}
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
@@ -354,10 +421,13 @@ public class ItemCountService : IItemCountService
}
using var dbContext = _dbProvider.CreateDbContext();
var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;
// Merged series and seasons are stored as one row per folder-item sharing a presentation key.
var groups = GetPresentationKeyGroups(dbContext, folderIds);
var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
var leafItems = dbContext.BaseItems
.Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
@@ -399,7 +469,7 @@ public class ItemCountService : IItemCountService
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
var results = ancestorLeaves
var countsByFolder = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
@@ -411,9 +481,73 @@ public class ItemCountService : IItemCountService
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
var results = new Dictionary<Guid, (int Played, int Total)>();
foreach (var (folderId, members) in groups)
{
var played = 0;
var total = 0;
// Members of a group are distinct folders, so their leaves cannot overlap.
foreach (var member in members)
{
if (countsByFolder.TryGetValue(member, out var counts))
{
played += counts.Played;
total += counts.Total;
}
}
if (total > 0 || played > 0)
{
results[folderId] = (played, total);
}
}
return results;
}
private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds)
{
var requested = dbContext.BaseItems
.AsNoTracking()
.WhereOneOrMany(folderIds, e => e.Id)
.Select(e => new { e.Id, e.PresentationUniqueKey })
.ToArray();
var keys = requested
.Select(e => e.PresentationUniqueKey)
.Where(key => !string.IsNullOrEmpty(key))
.Distinct(StringComparer.Ordinal)
.ToArray();
// Every item that is not merged carries a key derived from its own id, so in the common case
// each group resolves back to the single folder that was asked for.
var membersByKey = keys.Length == 0
? []
: dbContext.BaseItems
.AsNoTracking()
.Where(e => e.IsFolder)
.WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
.Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
.ToArray()
.GroupBy(e => e.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
var groups = new Dictionary<Guid, List<Guid>>();
foreach (var folderId in folderIds)
{
groups[folderId] = keyById.TryGetValue(folderId, out var key)
&& !string.IsNullOrEmpty(key)
&& membersByKey.TryGetValue(key, out var members)
&& members.Count > 0
? members
: [folderId];
}
return groups;
}
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query