From 95281a2205c2ae2252ae48e23eab5079dd620278 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 29 Aug 2026 21:27:24 +0200 Subject: Revert "Fix ParentId for episodes in virtual seasons" This reverts commit 6da85a0aaa87bdd2c81ef2b1936a2e35c1478b76. --- MediaBrowser.Providers/TV/SeriesMetadataService.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 803fab538f..b350f482c3 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -364,7 +364,7 @@ public class SeriesMetadataService : MetadataService 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 // 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); } } -- cgit v1.2.3 From 27d898e59e5c225baa3db0ee114f1c4034b74e31 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 29 Aug 2026 21:39:14 +0200 Subject: Count a season's episodes by the season they belong to --- Emby.Server.Implementations/Dto/DtoService.cs | 5 +- .../Library/LibraryManager.cs | 4 +- .../Item/ItemCountService.cs | 31 ++++++++-- MediaBrowser.Controller/Library/ILibraryManager.cs | 4 +- .../Persistence/IItemCountService.cs | 4 +- .../Dto/DtoServiceTests.cs | 2 +- .../Item/ItemCountServiceTests.cs | 72 ++++++++++++++++++++++ 7 files changed, 107 insertions(+), 15 deletions(-) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 2462a754ae..a2d3e14439 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -192,7 +192,7 @@ namespace Emby.Server.Implementations.Dto var folderIds = accessibleItems.OfType().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); } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index dd8c883684..71e2129ff8 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1745,9 +1745,9 @@ namespace Emby.Server.Implementations.Library return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query); } - public Dictionary GetChildCountBatch(IReadOnlyList parentIds, Guid? userId) + public Dictionary GetChildCountBatch(IReadOnlyList parentIds, User? user) { - return _countService.GetChildCountBatch(parentIds, userId); + return _countService.GetChildCountBatch(parentIds, user); } /// diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index c42b5f9581..14b120363f 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -319,7 +319,7 @@ public class ItemCountService : IItemCountService } /// - public Dictionary GetChildCountBatch(IReadOnlyList parentIds, Guid? userId) + public Dictionary GetChildCountBatch(IReadOnlyList 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(); 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 GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList parentIds) + private static Dictionary GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList 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, diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 2a6ea214b8..c8cca1fa93 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -758,9 +758,9 @@ namespace MediaBrowser.Controller.Library /// Returns the count of immediate children (non-recursive) for each parent. /// /// The list of parent folder IDs. - /// The user ID for access filtering. + /// The user the counts are for, or null to count without a user's preferences. /// Dictionary mapping parent ID to child count. - Dictionary GetChildCountBatch(IReadOnlyList parentIds, Guid? userId); + Dictionary GetChildCountBatch(IReadOnlyList parentIds, User? user); /// /// Batch-fetches played and total counts for multiple folder items. diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs index d57f1fc893..8ddf93e3e0 100644 --- a/MediaBrowser.Controller/Persistence/IItemCountService.cs +++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs @@ -80,7 +80,7 @@ public interface IItemCountService /// Batch-fetches child counts for multiple parent folders. /// /// The list of parent folder IDs. - /// The user ID for access filtering. + /// The user the counts are for, or null to count without a user's preferences. /// Dictionary mapping parent ID to child count. - Dictionary GetChildCountBatch(IReadOnlyList parentIds, Guid? userId); + Dictionary GetChildCountBatch(IReadOnlyList parentIds, User? user); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index bdac59c013..679e6d17e3 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -154,7 +154,7 @@ public class DtoServiceTests .Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny>(), user)) .Returns(new Dictionary { [season.Id] = (playedCount, totalCount) }); _libraryManagerMock - .Setup(x => x.GetChildCountBatch(It.IsAny>(), It.IsAny())) + .Setup(x => x.GetChildCountBatch(It.IsAny>(), It.IsAny())) .Returns(new Dictionary { [season.Id] = childCount }); return (season, user); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index 947cf54d85..fea743f08e 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -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"); -- cgit v1.2.3