diff options
| author | Cody Robibero <cody@robibe.ro> | 2026-08-23 08:20:29 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-23 08:20:29 -0400 |
| commit | af8e19b16929188a4621a17c4a2f4d2008f886ec (patch) | |
| tree | 91e4afec2dd2f6927db6b305f8beb013bc9a623f | |
| parent | 75ae31895ff0f9965fb504ac34730497206b27b6 (diff) | |
| parent | ea2c794cb2520ccb2d322da8c186875b7cebe006 (diff) | |
Merge pull request #17693 from Shadowghost/fix-series-merging
Fix series merging leaking across libraries and under-counting merged children
| -rw-r--r-- | Jellyfin.Server.Implementations/Item/ItemCountService.cs | 148 | ||||
| -rw-r--r-- | Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs (renamed from Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs) | 79 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/TV/Series.cs | 23 | ||||
| -rw-r--r-- | src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs | 58 | ||||
| -rw-r--r-- | tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs | 159 |
5 files changed, 423 insertions, 44 deletions
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index a320ba89d1..c42b5f9581 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -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).ToArray(); + + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(descendantIds, b => b.Id) + .Where(DescendantQueryHelper.IsCountableLeaf); + + return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); + } + /// <inheritdoc/> public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) { @@ -294,9 +309,9 @@ public class ItemCountService : IItemCountService ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId); + var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray(); var baseQuery = dbContext.BaseItems - .Where(b => allDescendantIds.Contains(b.Id)) + .WhereOneOrMany(allDescendantIds, b => b.Id) .Where(DescendantQueryHelper.IsCountableLeaf); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); @@ -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 diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs index 60bb3fd1db..0e50ec2f47 100644 --- a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs +++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs @@ -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; } } diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 3ce241aca8..1a1da84b7a 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -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) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 6b08f8dd7e..b821476390 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -32,18 +32,36 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); - var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId); + return AllDescendants(context, [parentId]) + .Where(e => !e.Equals(parentId)) + .Distinct(); + } - var hierarchyDescendants = ClosureDescendants(context, closureRoots); + /// <summary> + /// Gets all descendant IDs for multiple parent items in a single traversal. + /// Traverses AncestorIds and LinkedChildren, like <see cref="GetAllDescendantIds"/>, but resolves + /// the roots once for all seeds instead of once per seed. + /// </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); - var linkedDescendants = context.LinkedChildren - .WhereOneOrMany(linkRoots, e => e.ParentId) - .Select(e => e.ChildId); + if (parentIds.Count == 0) + { + return []; + } - return hierarchyDescendants - .Concat(linkedDescendants) - .Where(e => !e.Equals(parentId)) - .Distinct(); + var descendants = AllDescendants(context, parentIds) + .Distinct() + .ToHashSet(); + + descendants.ExceptWith(parentIds); + + return descendants; } /// <summary> @@ -216,6 +234,18 @@ public static class DescendantQueryHelper return query; } + private static IQueryable<Guid> AllDescendants(JellyfinDbContext context, IReadOnlyList<Guid> parentIds) + { + var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentIds); + + var linkedDescendants = context.LinkedChildren + .WhereOneOrMany(linkRoots, e => e.ParentId) + .Select(e => e.ChildId); + + return ClosureDescendants(context, closureRoots) + .Concat(linkedDescendants); + } + private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots) { var direct = context.AncestorIds @@ -286,12 +316,12 @@ public static class DescendantQueryHelper // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure, // and those contributing their linked children. - private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId) + private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, IReadOnlyList<Guid> parentIds) { - var closureRoots = new List<Guid> { parentId }; - var linkRoots = new List<Guid> { parentId }; - var visited = new HashSet<Guid> { parentId }; - var frontier = new List<Guid> { parentId }; + var visited = new HashSet<Guid>(parentIds); + var closureRoots = visited.ToList(); + var linkRoots = visited.ToList(); + var frontier = visited.ToList(); while (frontier.Count != 0) { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index 0766ca8d1e..947cf54d85 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -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 |
