Merge pull request #17735 from Shadowghost/fix-season-child-count
Count a season's episodes by the season they belong to
This commit is contained in:
@@ -192,7 +192,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
||||
if (folderIds.Count > 0)
|
||||
{
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +700,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
return count;
|
||||
}
|
||||
|
||||
// Fall back to individual query for special cases (Series, Season, etc.)
|
||||
// Only reached when no batch was computed: the batch holds an entry for every folder it
|
||||
// was asked about, zero included.
|
||||
return folder.GetChildCount(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -1745,9 +1745,9 @@ namespace Emby.Server.Implementations.Library
|
||||
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
|
||||
}
|
||||
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
return _countService.GetChildCountBatch(parentIds, userId);
|
||||
return _countService.GetChildCountBatch(parentIds, user);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -319,7 +319,7 @@ public class ItemCountService : IItemCountService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parentIds);
|
||||
|
||||
@@ -332,20 +332,32 @@ public class ItemCountService : IItemCountService
|
||||
|
||||
var parentIdsArray = parentIds.ToArray();
|
||||
|
||||
var includeVirtual = user is null || user.DisplayMissingEpisodes;
|
||||
|
||||
var hierarchicalCounts = dbContext.BaseItems
|
||||
.Where(b => b.ParentId.HasValue)
|
||||
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
|
||||
.GroupBy(b => b.ParentId!.Value)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.ParentId, x => x.Count);
|
||||
|
||||
// An episode is a child of its season even when it is not stored under one: with a flat
|
||||
// structure ParentId points at the series, so counting by ParentId alone leaves the season
|
||||
// empty and counts its episodes towards the series instead.
|
||||
var seasonCounts = dbContext.BaseItems
|
||||
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
|
||||
.GroupBy(b => b.SeasonId!.Value)
|
||||
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.SeasonId, x => x.Count);
|
||||
|
||||
var linkedCounts = dbContext.LinkedChildren
|
||||
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
|
||||
.GroupBy(lc => lc.ParentId)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.ParentId, x => x.Count);
|
||||
|
||||
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
|
||||
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual);
|
||||
|
||||
var result = new Dictionary<Guid, int>();
|
||||
foreach (var parentId in parentIds)
|
||||
@@ -356,7 +368,8 @@ public class ItemCountService : IItemCountService
|
||||
continue;
|
||||
}
|
||||
|
||||
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
|
||||
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0)
|
||||
+ seasonCounts.GetValueOrDefault(parentId, 0);
|
||||
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
|
||||
|
||||
result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
|
||||
@@ -365,7 +378,7 @@ public class ItemCountService : IItemCountService
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
|
||||
private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual)
|
||||
{
|
||||
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
|
||||
.Where(group => group.Value.Count > 1)
|
||||
@@ -380,10 +393,16 @@ public class ItemCountService : IItemCountService
|
||||
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
|
||||
var children = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.ParentId.HasValue)
|
||||
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
|
||||
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
|
||||
.ToArray()
|
||||
.Concat(dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
|
||||
.Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
|
||||
.ToArray())
|
||||
.GroupBy(b => b.ParentId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
|
||||
@@ -758,9 +758,9 @@ namespace MediaBrowser.Controller.Library
|
||||
/// Returns the count of immediate children (non-recursive) for each parent.
|
||||
/// </summary>
|
||||
/// <param name="parentIds">The list of parent folder IDs.</param>
|
||||
/// <param name="userId">The user ID for access filtering.</param>
|
||||
/// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
|
||||
/// <returns>Dictionary mapping parent ID to child count.</returns>
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
|
||||
|
||||
/// <summary>
|
||||
/// Batch-fetches played and total counts for multiple folder items.
|
||||
|
||||
@@ -80,7 +80,7 @@ public interface IItemCountService
|
||||
/// Batch-fetches child counts for multiple parent folders.
|
||||
/// </summary>
|
||||
/// <param name="parentIds">The list of parent folder IDs.</param>
|
||||
/// <param name="userId">The user ID for access filtering.</param>
|
||||
/// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
|
||||
/// <returns>Dictionary mapping parent ID to child count.</returns>
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
|
||||
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
|
||||
foreach (var episode in episodes)
|
||||
{
|
||||
var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber);
|
||||
if (season is null || (episode.SeasonId.Equals(season.Id) && episode.ParentId.Equals(season.Id)))
|
||||
if (season is null || episode.SeasonId.Equals(season.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -372,11 +372,6 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
|
||||
// Assign the correct season id and name to episode.
|
||||
episode.SeasonId = season.Id;
|
||||
episode.SeasonName = season.Name;
|
||||
|
||||
// We need to set ParentId here for episodes in virtual seasons (e.g., flat structures), otherwise it retains the
|
||||
// ParentId from the series.
|
||||
episode.SetParent(season);
|
||||
|
||||
await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ public class DtoServiceTests
|
||||
.Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user))
|
||||
.Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) });
|
||||
_libraryManagerMock
|
||||
.Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<Guid?>()))
|
||||
.Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()))
|
||||
.Returns(new Dictionary<Guid, int> { [season.Id] = childCount });
|
||||
|
||||
return (season, user);
|
||||
|
||||
@@ -198,6 +198,78 @@ public sealed class ItemCountServiceTests : IDisposable
|
||||
Assert.Equal(2, result[seriesB]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason()
|
||||
{
|
||||
var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false);
|
||||
|
||||
var result = _service.GetChildCountBatch([seriesId, seasonId], null);
|
||||
|
||||
Assert.Equal(2, result[seasonId]);
|
||||
|
||||
// The series holds the season, not the episodes: counting those here would double them up.
|
||||
Assert.Equal(1, result[seriesId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce()
|
||||
{
|
||||
var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false);
|
||||
|
||||
var result = _service.GetChildCountBatch([seriesId, seasonId], null);
|
||||
|
||||
Assert.Equal(2, result[seasonId]);
|
||||
Assert.Equal(1, result[seriesId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem()
|
||||
{
|
||||
var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
|
||||
var user = new User("count-test", "provider", "reset");
|
||||
|
||||
user.DisplayMissingEpisodes = true;
|
||||
Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]);
|
||||
|
||||
// Nothing this user can open, so nothing to report.
|
||||
user.DisplayMissingEpisodes = false;
|
||||
Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_NoUser_CountsMissingEpisodes()
|
||||
{
|
||||
var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
|
||||
|
||||
Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]);
|
||||
}
|
||||
|
||||
private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes)
|
||||
{
|
||||
var seriesId = Guid.NewGuid();
|
||||
var seasonId = Guid.NewGuid();
|
||||
|
||||
using var context = CreateDbContext();
|
||||
context.BaseItems.Add(CreateItem(seriesId));
|
||||
context.BaseItems.Add(CreateItem(seasonId, seriesId));
|
||||
|
||||
// Flat: the episodes sit in the series folder, so ParentId points at the series and only
|
||||
// SeasonId ties them to the season they belong to.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId);
|
||||
episode.Type = "MediaBrowser.Controller.Entities.TV.Episode";
|
||||
episode.IsFolder = false;
|
||||
episode.IsVirtualItem = virtualEpisodes;
|
||||
episode.SeasonId = seasonId;
|
||||
context.BaseItems.Add(episode);
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
return (seriesId, seasonId);
|
||||
}
|
||||
|
||||
private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
|
||||
{
|
||||
var user = new User("count-test", "provider", "reset");
|
||||
|
||||
Reference in New Issue
Block a user