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

View file

@ -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

View file

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -15,9 +17,9 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
/// </summary>
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
@ -53,6 +55,7 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
var newSeriesKeys = new Dictionary<Guid, string>();
var processed = 0;
var updated = 0;
@ -68,9 +71,10 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
}
var oldKey = item.PresentationUniqueKey;
var newKey = item.CreatePresentationUniqueKey();
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
newSeriesKeys[item.Id] = newKey;
if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
@ -82,21 +86,66 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
// to the series by it. Re-point every child still carrying the old key in a single set-based
// update so they stay attached without waiting for the next scan.
if (!string.IsNullOrEmpty(oldKey))
{
await dbContext.BaseItems
.Where(e => e.SeriesPresentationUniqueKey == oldKey)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
}
// Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
// re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
// by the old key: that key can be shared by every library holding the series, so matching
// on it would drag the other libraries' children along.
await dbContext.BaseItems
.Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
updated++;
}
var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}",
updated,
series.Length,
updatedSeasons,
sw.Elapsed);
}
}
private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken)
{
// A season's own key embeds its series' key, so it goes stale with it.
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Season]
}).OfType<Season>().ToArray();
var updated = 0;
foreach (var season in seasons)
{
cancellationToken.ThrowIfCancellationRequested();
// Without an index number the season keeps the base key, which carries no series key at all.
if (!season.IndexNumber.HasValue
|| !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey))
{
continue;
}
// Mirrors Season.CreatePresentationUniqueKey.
var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture);
if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
var id = season.Id;
await dbContext.BaseItems
.Where(e => e.Id.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
updated++;
}
_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
return updated;
}
}

View file

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@ -89,15 +90,14 @@ namespace MediaBrowser.Controller.Entities.TV
if (!string.IsNullOrEmpty(groupingKey))
{
return AppendPreferredLanguage(groupingKey);
return AddLibrariesToPresentationUniqueKey(groupingKey);
}
}
return base.CreatePresentationUniqueKey();
}
// The owning libraries are deliberately NOT part of the key.
private string AppendPreferredLanguage(string key)
private string AddLibrariesToPresentationUniqueKey(string key)
{
var lang = GetPreferredMetadataLanguage();
if (!string.IsNullOrEmpty(lang))
@ -105,7 +105,17 @@ namespace MediaBrowser.Controller.Entities.TV
key += "-" + lang;
}
return key;
var folders = LibraryManager.GetCollectionFolders(this)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.Order(StringComparer.Ordinal)
.ToArray();
if (folders.Length == 0)
{
return key;
}
return key + "-" + string.Join('-', folders);
}
private string GetNameBasedGroupingKey()
@ -125,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV
{
var seriesKey = GetUniqueSeriesKey(this);
var result = LibraryManager.GetCount(new InternalItemsQuery(user)
var result = LibraryManager.GetItemIds(new InternalItemsQuery(user)
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = new[] { BaseItemKind.Season },
IsVirtualItem = false,
Limit = 0,
DtoOptions = new DtoOptions(false)
{
EnableImages = false
}
});
return result;
return result.Count;
}
public override int GetRecursiveChildCount(User user)

View file

@ -39,6 +39,31 @@ public static class DescendantQueryHelper
return descendants.AsQueryable();
}
/// <summary>
/// Gets all descendant IDs for multiple parent items in a single traversal.
/// Traverses AncestorIds and LinkedChildren, like <see cref="GetAllDescendantIds"/>.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="parentIds">Parent item IDs.</param>
/// <returns>Set of all descendant item IDs (excluding the parent IDs themselves).</returns>
public static HashSet<Guid> GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(parentIds);
if (parentIds.Count == 0)
{
return [];
}
var seedSet = new HashSet<Guid>(parentIds);
var descendants = TraverseHierarchyDown(context, seedSet);
descendants.ExceptWith(seedSet);
return descendants;
}
/// <summary>
/// Gets a queryable of all owned descendant IDs for a parent item.
/// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations).

View file

@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Data.Sqlite;
@ -14,6 +15,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
namespace Jellyfin.Server.Implementations.Tests.Item;
@ -43,10 +45,18 @@ public sealed class ItemCountServiceTests : IDisposable
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
var queryHelpers = new Mock<IItemQueryHelpers>();
queryHelpers
.Setup(h => h.ApplyAccessFiltering(
It.IsAny<JellyfinDbContext>(),
It.IsAny<IQueryable<BaseItemEntity>>(),
It.IsAny<InternalItemsQuery>()))
.Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query);
_service = new ItemCountService(
factory.Object,
new Mock<IItemTypeLookup>().Object,
new Mock<IItemQueryHelpers>().Object);
queryHelpers.Object);
}
public void Dispose()
@ -106,6 +116,153 @@ public sealed class ItemCountServiceTests : IDisposable
Assert.Equal(parentIds.Count, result.Count);
}
[Fact]
public void GetCounts_MergedFolders_CountLeavesOfEveryFolderInTheGroup()
{
// Two folder-items of one merged series: same presentation key, a leaf each, one of them played.
var (user, seriesA, seriesB) = SeedMergedSeries(out var playedLeafId);
var filter = new InternalItemsQuery(user);
// Either folder-item stands for the whole merged series, so both must report the group.
foreach (var seriesId in new[] { seriesA, seriesB })
{
Assert.Equal(2, _service.GetTotalCount(filter, seriesId));
Assert.Equal(1, _service.GetPlayedCount(filter, seriesId));
Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, seriesId));
}
var batch = _service.GetPlayedAndTotalCountBatch([seriesA], user);
Assert.Equal((1, 2), batch[seriesA]);
Assert.NotEqual(Guid.Empty, playedLeafId);
}
[Fact]
public void GetCounts_UnmergedFolder_CountsOnlyItsOwnLeaves()
{
var (user, _, _) = SeedMergedSeries(out _);
// A folder with a key of its own must not pick up anything from the merged pair.
var loneSeriesId = Guid.NewGuid();
var loneLeafId = Guid.NewGuid();
using (var context = CreateDbContext())
{
var lone = CreateItem(loneSeriesId);
lone.PresentationUniqueKey = "lone-series";
context.BaseItems.Add(lone);
context.BaseItems.Add(CreateLeaf(loneLeafId));
context.SaveChanges();
AddAncestor(context, loneLeafId, loneSeriesId);
context.SaveChanges();
}
var filter = new InternalItemsQuery(user);
Assert.Equal(1, _service.GetTotalCount(filter, loneSeriesId));
Assert.Equal(0, _service.GetPlayedCount(filter, loneSeriesId));
Assert.Equal((0, 1), _service.GetPlayedAndTotalCount(filter, loneSeriesId));
}
[Fact]
public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys()
{
var seriesA = Guid.NewGuid();
var seriesB = Guid.NewGuid();
using (var context = CreateDbContext())
{
foreach (var id in new[] { seriesA, seriesB })
{
var series = CreateItem(id);
series.PresentationUniqueKey = "merged-series";
context.BaseItems.Add(series);
}
// Each folder-item holds a "Season 1"; those two share a key and are one season to the user.
var sharedSeasonA = CreateItem(Guid.NewGuid(), seriesA);
sharedSeasonA.PresentationUniqueKey = "merged-series-001";
var sharedSeasonB = CreateItem(Guid.NewGuid(), seriesB);
sharedSeasonB.PresentationUniqueKey = "merged-series-001";
var ownSeason = CreateItem(Guid.NewGuid(), seriesB);
ownSeason.PresentationUniqueKey = "merged-series-002";
context.BaseItems.AddRange(sharedSeasonA, sharedSeasonB, ownSeason);
context.SaveChanges();
}
var result = _service.GetChildCountBatch([seriesA, seriesB], null);
Assert.Equal(2, result[seriesA]);
Assert.Equal(2, result[seriesB]);
}
private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
{
var user = new User("count-test", "provider", "reset");
var seriesA = Guid.NewGuid();
var seriesB = Guid.NewGuid();
var leafA = Guid.NewGuid();
var leafB = Guid.NewGuid();
playedLeafId = leafA;
using (var context = CreateDbContext())
{
context.Users.Add(user);
foreach (var id in new[] { seriesA, seriesB })
{
var series = CreateItem(id);
series.PresentationUniqueKey = "merged-series";
context.BaseItems.Add(series);
}
context.BaseItems.AddRange(CreateLeaf(leafA), CreateLeaf(leafB));
context.SaveChanges();
AddAncestor(context, leafA, seriesA);
AddAncestor(context, leafB, seriesB);
context.UserData.Add(new UserData
{
ItemId = leafA,
UserId = user.Id,
CustomDataKey = string.Empty,
Played = true,
Item = null,
User = null
});
context.SaveChanges();
}
return (user, seriesA, seriesB);
}
private static void AddAncestor(JellyfinDbContext context, Guid itemId, Guid parentItemId)
{
context.AncestorIds.Add(new AncestorId
{
ItemId = itemId,
ParentItemId = parentItemId,
Item = null!,
ParentItem = null!
});
}
private static BaseItemEntity CreateLeaf(Guid id)
{
return new BaseItemEntity
{
Id = id,
Type = "Episode",
IsFolder = false,
IsVirtualItem = false,
PresentationUniqueKey = id.ToString("N")
};
}
private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
{
return new BaseItemEntity