mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-08-28 13:01:34 +00:00
Fix series merging leaking across libraries and under-counting merged children
This commit is contained in:
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue