Fix series merging leaking across libraries and under-counting merged children
This commit is contained in:
@@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
|
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(filter);
|
||||||
ArgumentNullException.ThrowIfNull(filter.User);
|
ArgumentNullException.ThrowIfNull(filter.User);
|
||||||
using var dbContext = _dbProvider.CreateDbContext();
|
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));
|
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
|
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(filter);
|
||||||
using var dbContext = _dbProvider.CreateDbContext();
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
|
||||||
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
|
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
|
||||||
return baseQuery.Count();
|
return baseQuery.Count();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService
|
|||||||
ArgumentNullException.ThrowIfNull(filter.User);
|
ArgumentNullException.ThrowIfNull(filter.User);
|
||||||
using var dbContext = _dbProvider.CreateDbContext();
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
|
||||||
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
|
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
|
||||||
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
|
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/>
|
/// <inheritdoc/>
|
||||||
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
|
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() })
|
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||||
.ToDictionary(x => x.ParentId, x => x.Count);
|
.ToDictionary(x => x.ParentId, x => x.Count);
|
||||||
|
|
||||||
|
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
|
||||||
|
|
||||||
var result = new Dictionary<Guid, int>();
|
var result = new Dictionary<Guid, int>();
|
||||||
foreach (var parentId in parentIds)
|
foreach (var parentId in parentIds)
|
||||||
{
|
{
|
||||||
|
if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
|
||||||
|
{
|
||||||
|
result[parentId] = mergedCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
|
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
|
||||||
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
|
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
|
||||||
|
|
||||||
@@ -342,6 +365,50 @@ public class ItemCountService : IItemCountService
|
|||||||
return result;
|
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/>
|
/// <inheritdoc/>
|
||||||
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
|
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();
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
var folderIdsArray = folderIds.ToArray();
|
|
||||||
var filter = new InternalItemsQuery(user);
|
var filter = new InternalItemsQuery(user);
|
||||||
var userId = user.Id;
|
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
|
var leafItems = dbContext.BaseItems
|
||||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||||
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
|
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
|
||||||
@@ -399,7 +469,7 @@ public class ItemCountService : IItemCountService
|
|||||||
b => b.Id,
|
b => b.Id,
|
||||||
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
|
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
|
||||||
|
|
||||||
var results = ancestorLeaves
|
var countsByFolder = ancestorLeaves
|
||||||
.Union(linkedLeaves)
|
.Union(linkedLeaves)
|
||||||
.Union(linkedFolderLeaves)
|
.Union(linkedFolderLeaves)
|
||||||
.GroupBy(x => x.FolderId)
|
.GroupBy(x => x.FolderId)
|
||||||
@@ -411,9 +481,73 @@ public class ItemCountService : IItemCountService
|
|||||||
})
|
})
|
||||||
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
|
.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;
|
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)
|
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
|
||||||
{
|
{
|
||||||
var result = query
|
var result = query
|
||||||
|
|||||||
+64
-15
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -15,9 +17,9 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Jellyfin.Server.Migrations.Routines;
|
namespace Jellyfin.Server.Migrations.Routines;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
|
[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
|
||||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||||
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
||||||
{
|
{
|
||||||
@@ -53,6 +55,7 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
const int ProgressInterval = 250;
|
const int ProgressInterval = 250;
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
var newSeriesKeys = new Dictionary<Guid, string>();
|
||||||
var processed = 0;
|
var processed = 0;
|
||||||
var updated = 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);
|
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
var oldKey = item.PresentationUniqueKey;
|
|
||||||
var newKey = item.CreatePresentationUniqueKey();
|
var newKey = item.CreatePresentationUniqueKey();
|
||||||
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
|
newSeriesKeys[item.Id] = newKey;
|
||||||
|
|
||||||
|
if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -82,21 +86,66 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
|||||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
|
// Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
|
||||||
// to the series by it. Re-point every child still carrying the old key in a single set-based
|
// re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
|
||||||
// update so they stay attached without waiting for the next scan.
|
// by the old key: that key can be shared by every library holding the series, so matching
|
||||||
if (!string.IsNullOrEmpty(oldKey))
|
// on it would drag the other libraries' children along.
|
||||||
{
|
await dbContext.BaseItems
|
||||||
await dbContext.BaseItems
|
.Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
|
||||||
.Where(e => e.SeriesPresentationUniqueKey == oldKey)
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
|
||||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
|
.ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
updated++;
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -89,15 +90,14 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(groupingKey))
|
if (!string.IsNullOrEmpty(groupingKey))
|
||||||
{
|
{
|
||||||
return AppendPreferredLanguage(groupingKey);
|
return AddLibrariesToPresentationUniqueKey(groupingKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return base.CreatePresentationUniqueKey();
|
return base.CreatePresentationUniqueKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The owning libraries are deliberately NOT part of the key.
|
private string AddLibrariesToPresentationUniqueKey(string key)
|
||||||
private string AppendPreferredLanguage(string key)
|
|
||||||
{
|
{
|
||||||
var lang = GetPreferredMetadataLanguage();
|
var lang = GetPreferredMetadataLanguage();
|
||||||
if (!string.IsNullOrEmpty(lang))
|
if (!string.IsNullOrEmpty(lang))
|
||||||
@@ -105,7 +105,17 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
key += "-" + lang;
|
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()
|
private string GetNameBasedGroupingKey()
|
||||||
@@ -125,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
{
|
{
|
||||||
var seriesKey = GetUniqueSeriesKey(this);
|
var seriesKey = GetUniqueSeriesKey(this);
|
||||||
|
|
||||||
var result = LibraryManager.GetCount(new InternalItemsQuery(user)
|
var result = LibraryManager.GetItemIds(new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
AncestorWithPresentationUniqueKey = null,
|
AncestorWithPresentationUniqueKey = null,
|
||||||
SeriesPresentationUniqueKey = seriesKey,
|
SeriesPresentationUniqueKey = seriesKey,
|
||||||
IncludeItemTypes = new[] { BaseItemKind.Season },
|
IncludeItemTypes = new[] { BaseItemKind.Season },
|
||||||
IsVirtualItem = false,
|
IsVirtualItem = false,
|
||||||
Limit = 0,
|
|
||||||
DtoOptions = new DtoOptions(false)
|
DtoOptions = new DtoOptions(false)
|
||||||
{
|
{
|
||||||
EnableImages = false
|
EnableImages = false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int GetRecursiveChildCount(User user)
|
public override int GetRecursiveChildCount(User user)
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ public static class DescendantQueryHelper
|
|||||||
return descendants.AsQueryable();
|
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>
|
/// <summary>
|
||||||
/// Gets a queryable of all owned descendant IDs for a parent item.
|
/// Gets a queryable of all owned descendant IDs for a parent item.
|
||||||
/// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations).
|
/// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations).
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations.Locking;
|
|||||||
using Jellyfin.Database.Providers.Sqlite;
|
using Jellyfin.Database.Providers.Sqlite;
|
||||||
using Jellyfin.Server.Implementations.Item;
|
using Jellyfin.Server.Implementations.Item;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
@@ -14,6 +15,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
|
||||||
|
|
||||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||||
|
|
||||||
@@ -43,10 +45,18 @@ public sealed class ItemCountServiceTests : IDisposable
|
|||||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
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(
|
_service = new ItemCountService(
|
||||||
factory.Object,
|
factory.Object,
|
||||||
new Mock<IItemTypeLookup>().Object,
|
new Mock<IItemTypeLookup>().Object,
|
||||||
new Mock<IItemQueryHelpers>().Object);
|
queryHelpers.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -106,6 +116,153 @@ public sealed class ItemCountServiceTests : IDisposable
|
|||||||
Assert.Equal(parentIds.Count, result.Count);
|
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)
|
private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
|
||||||
{
|
{
|
||||||
return new BaseItemEntity
|
return new BaseItemEntity
|
||||||
|
|||||||
Reference in New Issue
Block a user