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:
Cody Robibero
2026-08-30 13:29:13 -04:00
committed by GitHub
8 changed files with 108 additions and 21 deletions
@@ -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");