aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-08-23 08:20:29 -0400
committerGitHub <noreply@github.com>2026-08-23 08:20:29 -0400
commitaf8e19b16929188a4621a17c4a2f4d2008f886ec (patch)
tree91e4afec2dd2f6927db6b305f8beb013bc9a623f
parent75ae31895ff0f9965fb504ac34730497206b27b6 (diff)
parentea2c794cb2520ccb2d322da8c186875b7cebe006 (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.cs148
-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.cs23
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs58
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs159
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