Optimize Collection Grouping, NextUp and Latest queries

This commit is contained in:
Shadowghost
2026-02-07 00:56:55 +01:00
parent 8ddc35a1ce
commit 268d88a5fb
9 changed files with 365 additions and 214 deletions

View File

@@ -185,10 +185,23 @@ namespace Emby.Server.Implementations.Dto
}
}
// Batch-fetch played/total counts for all folders to avoid N+1 queries
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null;
if (user is not null && options.EnableUserData)
{
var folderIds = accessibleItems.OfType<Folder>()
.Where(f => f.SupportsUserDataFromChildren && (f.SupportsPlayedStatus || options.ContainsField(ItemFields.RecursiveItemCount)))
.Select(f => f.Id).ToList();
if (folderIds.Count > 0)
{
playedCountBatch = _libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
}
}
for (int index = 0; index < accessibleItems.Count; index++)
{
var item = accessibleItems[index];
var dto = GetBaseItemDtoInternal(item, options, user, owner, userDataBatch?.GetValueOrDefault(item.Id), allCollectionFolders, childCountBatch);
var dto = GetBaseItemDtoInternal(item, options, user, owner, userDataBatch?.GetValueOrDefault(item.Id), allCollectionFolders, childCountBatch, playedCountBatch);
if (item is LiveTvChannel tvChannel)
{
@@ -240,7 +253,7 @@ namespace Emby.Server.Implementations.Dto
return dto;
}
private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null, UserItemData? userData = null, List<Folder>? allCollectionFolders = null, Dictionary<Guid, int>? childCountBatch = null)
private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null, UserItemData? userData = null, List<Folder>? allCollectionFolders = null, Dictionary<Guid, int>? childCountBatch = null, Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
{
var dto = new BaseItemDto
{
@@ -277,7 +290,7 @@ namespace Emby.Server.Implementations.Dto
if (user is not null)
{
AttachUserSpecificInfo(dto, item, user, options, userData, childCountBatch);
AttachUserSpecificInfo(dto, item, user, options, userData, childCountBatch, playedCountBatch);
}
if (item is IHasMediaSources
@@ -485,7 +498,7 @@ namespace Emby.Server.Implementations.Dto
/// <summary>
/// Attaches the user specific info.
/// </summary>
private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options, UserItemData? userData = null, Dictionary<Guid, int>? childCountBatch = null)
private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options, UserItemData? userData = null, Dictionary<Guid, int>? childCountBatch = null, Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
{
if (item.IsFolder)
{
@@ -497,7 +510,8 @@ namespace Emby.Server.Implementations.Dto
{
// Use pre-fetched user data
dto.UserData = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
(int Played, int Total)? precomputed = playedCountBatch is not null && playedCountBatch.TryGetValue(item.Id, out var counts) ? counts : null;
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options, precomputed);
}
else
{

View File

@@ -1515,6 +1515,12 @@ namespace Emby.Server.Implementations.Library
return _itemRepository.GetChildCountBatch(parentIds, userId);
}
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
return _itemRepository.GetPlayedAndTotalCountBatch(folderIds, user);
}
public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents)
{
SetTopParentIdsOrAncestors(query, parents);

View File

@@ -564,25 +564,35 @@ public class UserLibraryController : BaseJellyfinApiController
},
dtoOptions);
var dtos = list.Select(i =>
var resolvedItems = new BaseItem[list.Count];
var childCounts = new int[list.Count];
for (int i = 0; i < list.Count; i++)
{
var item = i.Item2[0];
var tuple = list[i];
var item = tuple.Item2[0];
var childCount = 0;
if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum || i.Item1 is Series ))
if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum || tuple.Item1 is Series))
{
item = i.Item1;
childCount = i.Item2.Count;
item = tuple.Item1;
childCount = tuple.Item2.Count;
}
var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
resolvedItems[i] = item;
childCounts[i] = childCount;
}
dto.ChildCount = childCount;
// Fetch DTOs without visibility check since we've already done that in GetLatestItems and restore child counts afterwards
var dtos = _dtoService.GetBaseItemDtos(resolvedItems, dtoOptions, user, skipVisibilityCheck: true);
for (int i = 0; i < dtos.Count; i++)
{
if (childCounts[i] > 0)
{
dtos[i].ChildCount = childCounts[i];
}
}
return dto;
});
return Ok(dtos);
return Ok(dtos.AsEnumerable());
}
/// <summary>

View File

@@ -369,16 +369,10 @@ public sealed class BaseItemRepository
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
// Determine the grouping key selector based on collection type
// Movies: PresentationUniqueKey (groups alternate versions like 4K/1080p)
// Music: Album
Func<BaseItemEntity, string?> groupKeySelector = collectionType switch
{
CollectionType.movies => e => e.PresentationUniqueKey,
_ => e => e.Album
};
IQueryable<string> topGroupKeys;
// Find the top N group keys ordered by most recent DateCreated.
// Movies group by PresentationUniqueKey (alternate versions like 4K/1080p share a key).
// Music groups by Album.
List<string> topGroupKeys;
if (collectionType is CollectionType.movies)
{
topGroupKeys = baseQuery
@@ -387,7 +381,8 @@ public sealed class BaseItemRepository
.Select(g => new { GroupKey = g.Key!, MaxDate = g.Max(e => e.DateCreated) })
.OrderByDescending(g => g.MaxDate)
.Take(limit)
.Select(g => g.GroupKey);
.Select(g => g.GroupKey)
.ToList();
}
else
{
@@ -397,22 +392,39 @@ public sealed class BaseItemRepository
.Select(g => new { GroupKey = g.Key!, MaxDate = g.Max(e => e.DateCreated) })
.OrderByDescending(g => g.MaxDate)
.Take(limit)
.Select(g => g.GroupKey);
.Select(g => g.GroupKey)
.ToList();
}
var itemsQuery = collectionType switch
// Get only the first (most recent) item ID per group using a lightweight projection,
// then fetch full entities only for those items. This avoids loading all versions/tracks
// with expensive navigation properties just to discard duplicates.
var allItemsLite = collectionType switch
{
CollectionType.movies => baseQuery.Where(e => e.PresentationUniqueKey != null && topGroupKeys.Contains(e.PresentationUniqueKey)),
_ => baseQuery.Where(e => e.Album != null && topGroupKeys.Contains(e.Album))
CollectionType.movies => baseQuery
.Where(e => e.PresentationUniqueKey != null && topGroupKeys.Contains(e.PresentationUniqueKey))
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.Select(e => new { e.Id, GroupKey = e.PresentationUniqueKey })
.ToList(),
_ => baseQuery
.Where(e => e.Album != null && topGroupKeys.Contains(e.Album))
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.Select(e => new { e.Id, GroupKey = e.Album })
.ToList()
};
itemsQuery = itemsQuery.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id);
var firstIds = allItemsLite
.DistinctBy(e => e.GroupKey)
.Select(e => e.Id)
.ToList();
var itemsQuery = context.BaseItems.AsNoTracking().Where(e => firstIds.Contains(e.Id));
itemsQuery = ApplyNavigations(itemsQuery, filter).AsSingleQuery();
return itemsQuery
.AsEnumerable()
.GroupBy(groupKeySelector)
.Select(g => g.First())
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
@@ -450,35 +462,48 @@ public sealed class BaseItemRepository
const double RecentAdditionWindowHours = 24.0;
// Step 1: Find the top N series with recently added content, ordered by most recent addition
var topSeriesNames = baseQuery
var topSeriesWithDates = baseQuery
.Where(e => e.SeriesName != null)
.GroupBy(e => e.SeriesName)
.Select(g => new { SeriesName = g.Key!, MaxDate = g.Max(e => e.DateCreated) })
.OrderByDescending(g => g.MaxDate)
.Take(limit)
.Select(g => g.SeriesName)
.ToList();
// Step 2: Fetch all episodes from the identified series (needed for analysis)
var allEpisodes = ApplyNavigations(
baseQuery
.Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName))
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id),
filter)
.AsSingleQuery()
var topSeriesNames = topSeriesWithDates.Select(g => g.SeriesName).ToList();
// Compute a global date cutoff: the oldest series' max date minus the window.
// Episodes before this cutoff cannot be in any series' "recent additions" window,
// so we can safely exclude them to avoid loading ancient episodes.
var globalCutoff = topSeriesWithDates.Count > 0
? topSeriesWithDates.Min(g => g.MaxDate)?.AddHours(-RecentAdditionWindowHours)
: null;
// Fetch only the columns needed for analysis (lightweight projection).
var episodeQuery = baseQuery
.Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName));
if (globalCutoff is not null)
{
episodeQuery = episodeQuery.Where(e => e.DateCreated >= globalCutoff);
}
var allEpisodes = episodeQuery
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.Select(e => new { e.Id, e.SeriesName, e.DateCreated, e.SeasonId, e.SeriesId })
.ToList();
// Collect all season/series IDs we'll need to look up for count information
var allSeasonIds = new HashSet<Guid>();
var allSeriesIds = new HashSet<Guid>();
// Analysis data for each series: which episodes were recently added and to which seasons
// Analysis data for each series: recent episode count, season IDs, and the most recent episode ID
var analysisData = new List<(
List<BaseItemEntity> RecentEpisodes,
int RecentEpisodeCount,
List<Guid> SeasonIds,
Guid? FirstRecentSeriesId,
DateTime MaxDate,
BaseItemEntity MostRecentEpisode)>();
Guid MostRecentEpisodeId)>();
// Step 3: Analyze each series to identify recent additions within the time window
foreach (var group in allEpisodes.GroupBy(e => e.SeriesName))
@@ -488,23 +513,26 @@ public sealed class BaseItemRepository
var recentCutoff = mostRecentDate.AddHours(-RecentAdditionWindowHours);
// Find episodes added within the recent window
var recentEpisodes = new List<BaseItemEntity>();
var recentEpisodeCount = 0;
var seasonIdSet = new HashSet<Guid>();
Guid? firstRecentSeriesId = null;
foreach (var ep in episodes)
{
if (ep.DateCreated >= recentCutoff)
{
recentEpisodes.Add(ep);
recentEpisodeCount++;
if (ep.SeasonId.HasValue)
{
seasonIdSet.Add(ep.SeasonId.Value);
}
firstRecentSeriesId ??= ep.SeriesId;
}
}
var seasonIds = seasonIdSet.ToList();
analysisData.Add((recentEpisodes, seasonIds, mostRecentDate, episodes[0]));
analysisData.Add((recentEpisodeCount, seasonIds, firstRecentSeriesId, mostRecentDate, episodes[0].Id));
// Track all unique season/series IDs for batch lookups
foreach (var sid in seasonIds)
@@ -512,9 +540,9 @@ public sealed class BaseItemRepository
allSeasonIds.Add(sid);
}
if (recentEpisodes.Count > 0 && recentEpisodes[0].SeriesId.HasValue)
if (firstRecentSeriesId.HasValue)
{
allSeriesIds.Add(recentEpisodes[0].SeriesId!.Value);
allSeriesIds.Add(firstRecentSeriesId.Value);
}
}
@@ -542,9 +570,9 @@ public sealed class BaseItemRepository
// Step 5: Apply the container selection logic for each series
var entitiesToFetch = new HashSet<Guid>();
var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, BaseItemEntity MostRecentEpisode)>(analysisData.Count);
var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, Guid MostRecentEpisodeId)>(analysisData.Count);
foreach (var (recentEpisodes, seasonIds, maxDate, mostRecentEpisode) in analysisData)
foreach (var (recentEpisodeCount, seasonIds, firstRecentSeriesId, maxDate, mostRecentEpisodeId) in analysisData)
{
Guid? seasonId = null;
Guid? seriesId = null;
@@ -554,13 +582,12 @@ public sealed class BaseItemRepository
// All recent episodes are from a single season
var sid = seasonIds[0];
var totalEpisodes = seasonEpisodeCounts.GetValueOrDefault(sid, 0);
var episodeSeriesId = recentEpisodes.Count > 0 ? recentEpisodes[0].SeriesId : null;
var totalSeasonsInSeries = episodeSeriesId.HasValue
? seriesSeasonCounts.GetValueOrDefault(episodeSeriesId.Value, 1)
var totalSeasonsInSeries = firstRecentSeriesId.HasValue
? seriesSeasonCounts.GetValueOrDefault(firstRecentSeriesId.Value, 1)
: 1;
// Check if multiple episodes were added, or if all episodes in the season were added
var hasMultipleOrAllEpisodes = recentEpisodes.Count > 1 || recentEpisodes.Count == totalEpisodes;
var hasMultipleOrAllEpisodes = recentEpisodeCount > 1 || recentEpisodeCount == totalEpisodes;
if (totalSeasonsInSeries > 1 && hasMultipleOrAllEpisodes)
{
@@ -568,23 +595,28 @@ public sealed class BaseItemRepository
seasonId = sid;
entitiesToFetch.Add(sid);
}
else if (hasMultipleOrAllEpisodes && episodeSeriesId.HasValue)
else if (hasMultipleOrAllEpisodes && firstRecentSeriesId.HasValue)
{
// Single-season series with bulk additions: show the Series
seriesId = episodeSeriesId;
entitiesToFetch.Add(episodeSeriesId.Value);
seriesId = firstRecentSeriesId;
entitiesToFetch.Add(firstRecentSeriesId.Value);
}
// Otherwise: single episode, will fall through to show the Episode
}
else if (seasonIds.Count > 1 && recentEpisodes.Count > 0 && recentEpisodes[0].SeriesId.HasValue)
else if (seasonIds.Count > 1 && firstRecentSeriesId.HasValue)
{
// Recent episodes span multiple seasons: show the Series
seriesId = recentEpisodes[0].SeriesId;
seriesId = firstRecentSeriesId;
entitiesToFetch.Add(seriesId!.Value);
}
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisode));
if (seasonId is null && seriesId is null)
{
entitiesToFetch.Add(mostRecentEpisodeId);
}
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
}
// Step 6: Fetch the Season/Series entities we decided to return
@@ -596,9 +628,10 @@ public sealed class BaseItemRepository
.ToDictionary(e => e.Id)
: [];
// Step 7: Build final results, preferring Season > Series > Episode
// Step 7: Build final results, preferring Season > Series > Episode.
// All needed entities are already fetched in step 6 with navigation properties.
var results = new List<(BaseItemEntity Entity, DateTime MaxDate)>(seriesResults.Count);
foreach (var (seasonId, seriesId, maxDate, mostRecentEpisode) in seriesResults)
foreach (var (seasonId, seriesId, maxDate, mostRecentEpisodeId) in seriesResults)
{
if (seasonId.HasValue && entities.TryGetValue(seasonId.Value, out var seasonEntity))
{
@@ -612,8 +645,10 @@ public sealed class BaseItemRepository
continue;
}
// Fallback: show the most recent episode
results.Add((mostRecentEpisode, maxDate));
if (entities.TryGetValue(mostRecentEpisodeId, out var episodeEntity))
{
results.Add((episodeEntity, maxDate));
}
}
return results
@@ -733,94 +768,73 @@ public sealed class BaseItemRepository
Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new();
if (includeSpecials)
{
var allSpecials = context.BaseItems
var specialsQuery = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber == 0)
.Where(e => !e.IsVirtualItem)
.ToList();
.Where(e => !e.IsVirtualItem);
specialsQuery = ApplyNavigations(specialsQuery, filter).AsSingleQuery();
var specialIds = allSpecials.Select(s => s.Id).ToList();
if (specialIds.Count > 0)
foreach (var special in specialsQuery)
{
var specialsWithNav = context.BaseItems.AsNoTracking().Where(e => specialIds.Contains(e.Id));
specialsWithNav = ApplyNavigations(specialsWithNav, filter).AsSingleQuery();
var specialsDict = specialsWithNav.ToDictionary(e => e.Id);
foreach (var special in allSpecials)
var key = special.SeriesPresentationUniqueKey!;
if (!specialsBySeriesKey.TryGetValue(key, out var list))
{
var key = special.SeriesPresentationUniqueKey!;
if (!specialsBySeriesKey.TryGetValue(key, out var list))
{
list = new List<BaseItemEntity>();
specialsBySeriesKey[key] = list;
}
if (specialsDict.TryGetValue(special.Id, out var specialWithNav))
{
list.Add(specialWithNav);
}
list = new List<BaseItemEntity>();
specialsBySeriesKey[key] = list;
}
list.Add(special);
}
}
var nextEpisodeIds = new HashSet<Guid>();
var seriesNextIdMap = new Dictionary<string, Guid>();
var seriesNextPlayedIdMap = new Dictionary<string, Guid>();
var allCandidatesWithPlayedStatus = context.BaseItems
// Build position lookup from already-loaded last watched data
var positionLookup = new Dictionary<string, (int Season, int Episode)>();
foreach (var kvp in lastWatchedInfo)
{
if (kvp.Value != Guid.Empty
&& lastWatchedEpisodes.TryGetValue(kvp.Value, out var lw)
&& lw.ParentIndexNumber.HasValue
&& lw.IndexNumber.HasValue)
{
positionLookup[kvp.Key] = (lw.ParentIndexNumber.Value, lw.IndexNumber.Value);
}
}
// Single query: fetch all unplayed non-virtual non-special episodes for all series.
// Uses NOT EXISTS (via !Any) for the played check, which is more efficient than GroupJoin.
// Only unplayed episodes are loaded (typically ~10% of total), keeping memory usage low.
var allUnplayedCandidates = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
.GroupJoin(
context.UserData.AsNoTracking().Where(ud => ud.UserId == userId),
e => e.Id,
ud => ud.ItemId,
(episode, userData) => new
{
episode.Id,
episode.SeriesPresentationUniqueKey,
episode.ParentIndexNumber,
EpisodeNumber = episode.IndexNumber,
IsPlayed = userData.Any(ud => ud.Played)
})
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
.Select(e => new
{
e.Id,
e.SeriesPresentationUniqueKey,
e.ParentIndexNumber,
EpisodeNumber = e.IndexNumber
})
.ToList();
// For regular NextUp: unplayed episodes
var allNextUpCandidates = allCandidatesWithPlayedStatus
.Where(c => !c.IsPlayed)
.Select(c => new { c.Id, c.SeriesPresentationUniqueKey, c.ParentIndexNumber, c.EpisodeNumber })
.ToList();
// For rewatching: played episodes (only used when includeWatchedForRewatching is true)
var allNextPlayedCandidates = includeWatchedForRewatching
? allCandidatesWithPlayedStatus
.Where(c => c.IsPlayed)
.Select(c => new { c.Id, c.SeriesPresentationUniqueKey, c.ParentIndexNumber, c.EpisodeNumber })
.ToList()
: [];
// In-memory: find the next unplayed episode per series, respecting last-watched position
var nextEpisodeIds = new HashSet<Guid>();
var seriesNextIdMap = new Dictionary<string, Guid>();
foreach (var seriesKey in seriesKeys)
{
var candidates = allNextUpCandidates
var candidates = allUnplayedCandidates
.Where(c => c.SeriesPresentationUniqueKey == seriesKey);
if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty)
if (positionLookup.TryGetValue(seriesKey, out var pos))
{
var lastWatchedEntity = lastWatchedEpisodes.GetValueOrDefault(lwId);
if (lastWatchedEntity is not null)
{
var season = lastWatchedEntity.ParentIndexNumber;
var episode = lastWatchedEntity.IndexNumber;
if (season.HasValue && episode.HasValue)
{
candidates = candidates.Where(c =>
c.ParentIndexNumber > season ||
(c.ParentIndexNumber == season && c.EpisodeNumber > episode));
}
}
candidates = candidates.Where(c =>
c.ParentIndexNumber > pos.Season
|| (c.ParentIndexNumber == pos.Season && c.EpisodeNumber > pos.Episode));
}
var nextCandidate = candidates
@@ -833,39 +847,67 @@ public sealed class BaseItemRepository
nextEpisodeIds.Add(nextCandidate.Id);
seriesNextIdMap[seriesKey] = nextCandidate.Id;
}
}
if (includeWatchedForRewatching && lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId))
{
var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId);
if (lastByDateEntity is not null)
// Find next played episode per series for rewatching mode
var seriesNextPlayedIdMap = new Dictionary<string, Guid>();
if (includeWatchedForRewatching)
{
var allPlayedCandidates = context.BaseItems
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
.Select(e => new
{
var lastSeason = lastByDateEntity.ParentIndexNumber;
var lastEp = lastByDateEntity.IndexNumber;
e.Id,
e.SeriesPresentationUniqueKey,
e.ParentIndexNumber,
EpisodeNumber = e.IndexNumber
})
.ToList();
var playedCandidates = allNextPlayedCandidates
.Where(c => c.SeriesPresentationUniqueKey == seriesKey);
foreach (var seriesKey in seriesKeys)
{
if (!lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId))
{
continue;
}
if (lastSeason.HasValue && lastEp.HasValue)
{
playedCandidates = playedCandidates.Where(c =>
c.ParentIndexNumber > lastSeason ||
(c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp));
}
var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId);
if (lastByDateEntity is null)
{
continue;
}
var nextPlayedCandidate = playedCandidates
.OrderBy(c => c.ParentIndexNumber)
.ThenBy(c => c.EpisodeNumber)
.FirstOrDefault();
var playedCandidates = allPlayedCandidates
.Where(c => c.SeriesPresentationUniqueKey == seriesKey);
if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty)
{
nextEpisodeIds.Add(nextPlayedCandidate.Id);
seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id;
}
if (lastByDateEntity.ParentIndexNumber.HasValue && lastByDateEntity.IndexNumber.HasValue)
{
var lastSeason = lastByDateEntity.ParentIndexNumber.Value;
var lastEp = lastByDateEntity.IndexNumber.Value;
playedCandidates = playedCandidates.Where(c =>
c.ParentIndexNumber > lastSeason
|| (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp));
}
var nextPlayedCandidate = playedCandidates
.OrderBy(c => c.ParentIndexNumber)
.ThenBy(c => c.EpisodeNumber)
.FirstOrDefault();
if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty)
{
nextEpisodeIds.Add(nextPlayedCandidate.Id);
seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id;
}
}
}
// Batch fetch all next episode entities with navigation properties
var nextEpisodes = new Dictionary<Guid, BaseItemEntity>();
if (nextEpisodeIds.Count > 0)
{
@@ -950,11 +992,47 @@ public sealed class BaseItemRepository
dbQuery = dbQuery.Distinct();
}
if (filter.CollapseBoxSetItems == true)
{
dbQuery = ApplyBoxSetCollapsing(context, dbQuery);
}
dbQuery = ApplyOrder(dbQuery, filter, context);
return dbQuery;
}
private IQueryable<BaseItemEntity> ApplyBoxSetCollapsing(
JellyfinDbContext context,
IQueryable<BaseItemEntity> dbQuery)
{
var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
var currentIds = dbQuery.Select(e => e.Id);
// Items that are NOT box sets and NOT in any box set
var notInBoxSet = currentIds
.Where(id =>
!context.BaseItems.Any(bs => bs.Id == id && bs.Type == boxSetTypeName)
&& !context.LinkedChildren.Any(lc =>
lc.ChildId == id
&& lc.ChildType == DbLinkedChildType.Manual
&& context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName)));
// Box set IDs containing at least one accessible child item.
// Access filtering is already applied to currentIds via TranslateQuery
var boxSetIds = context.LinkedChildren
.Where(lc =>
lc.ChildType == DbLinkedChildType.Manual
&& currentIds.Contains(lc.ChildId)
&& context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName))
.Select(lc => lc.ParentId)
.Distinct();
var collapsedIds = notInBoxSet.Union(boxSetIds);
return context.BaseItems.Where(e => collapsedIds.Contains(e.Id));
}
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
@@ -1002,7 +1080,7 @@ public sealed class BaseItemRepository
dbQuery = dbQuery.Include(e => e.Extras);
}
return dbQuery.AsSingleQuery();
return dbQuery;
}
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
@@ -1709,8 +1787,7 @@ public sealed class BaseItemRepository
.Include(e => e.LockedFields)
.Include(e => e.UserData)
.Include(e => e.Images)
.Include(e => e.LinkedChildEntities)
.AsSingleQuery();
.Include(e => e.LinkedChildEntities);
var item = dbQuery.FirstOrDefault(e => e.Id == id);
if (item is null)
@@ -3745,6 +3822,52 @@ public sealed class BaseItemRepository
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
ArgumentNullException.ThrowIfNull(folderIds);
ArgumentNullException.ThrowIfNull(user);
if (folderIds.Count == 0)
{
return new Dictionary<Guid, (int Played, int Total)>();
}
using var dbContext = _dbProvider.CreateDbContext();
var folderIdsArray = folderIds.ToArray();
// Build access filter from user preferences (parental ratings, blocked/allowed tags, etc.)
var filter = new InternalItemsQuery(user);
// Get all non-folder, non-virtual descendants via AncestorIds table
var baseQuery = dbContext.BaseItems
.Where(b => dbContext.AncestorIds
.Any(a => folderIdsArray.Contains(a.ParentItemId) && a.ItemId == b.Id))
.Where(b => !b.IsFolder && !b.IsVirtualItem);
// Apply the same access filtering as per-item path
baseQuery = ApplyAccessFiltering(dbContext, baseQuery, filter);
// Join back with AncestorIds to group by parent folder ID and compute counts
var results = dbContext.AncestorIds
.Where(a => folderIdsArray.Contains(a.ParentItemId))
.Join(
baseQuery,
a => a.ItemId,
b => b.Id,
(a, b) => new { a.ParentItemId, b.Id, b.UserData })
.GroupBy(x => x.ParentItemId)
.Select(g => new
{
FolderId = g.Key,
Total = g.Count(),
Played = g.Count(x => x.UserData!.Any(ud => ud.UserId == user.Id && ud.Played))
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
return results;
}
/// <inheritdoc/>
public IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null)
{

View File

@@ -1693,7 +1693,7 @@ namespace MediaBrowser.Controller.Entities
return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck)
protected bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck)
{
var blockedTags = user.GetPreference(PreferenceKind.BlockedTags);
var allowedTags = user.GetPreference(PreferenceKind.AllowedTags);
@@ -2487,7 +2487,7 @@ namespace MediaBrowser.Controller.Entities
return path;
}
public virtual void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields)
public virtual void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields, (int Played, int Total)? precomputedCounts = null)
{
if (RunTimeTicks.HasValue)
{

View File

@@ -730,36 +730,9 @@ namespace MediaBrowser.Controller.Entities
public QueryResult<BaseItem> QueryRecursive(InternalItemsQuery query)
{
var user = query.User;
if (!query.ForceDirect && RequiresPostFiltering(query))
{
IEnumerable<BaseItem> items;
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
var totalCount = 0;
if (query.User is null)
{
items = GetRecursiveChildren(filter);
totalCount = items.Count();
}
else
{
// Save pagination params before clearing them to prevent pagination from happening
// before sorting. PostFilterAndSort will apply pagination after sorting.
var limit = query.Limit;
var startIndex = query.StartIndex;
query.Limit = null;
query.StartIndex = null;
items = GetRecursiveChildren(user, query, out totalCount);
// Restore pagination params so PostFilterAndSort can apply them after sorting
query.Limit = limit;
query.StartIndex = startIndex;
}
return PostFilterAndSort(items, query);
query.CollapseBoxSetItems = true;
}
if (this is not UserRootFolder
@@ -1690,7 +1663,7 @@ namespace MediaBrowser.Controller.Entities
return !IsPlayed(user, userItemData);
}
public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields)
public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields, (int Played, int Total)? precomputedCounts = null)
{
if (!SupportsUserDataFromChildren)
{
@@ -1699,22 +1672,28 @@ namespace MediaBrowser.Controller.Entities
if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)))
{
// Create a minimal query with just the user - skip ConfigureUserAccess to avoid
// expensive GetUserViews calls. Since we're counting descendants of a specific
// item (this folder) that the user already has access to, TopParentIds filtering
// is redundant. The parental rating filter is applied via query.User.
var query = new InternalItemsQuery(user);
int playedCount;
int totalCount;
if (LinkedChildren.Length > 0)
if (precomputedCounts.HasValue && LinkedChildren.Length == 0)
{
(playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCountFromLinkedChildren(query, Id);
// Use batch-fetched counts (avoids N+1 queries)
(playedCount, totalCount) = precomputedCounts.Value;
}
else
{
(playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCount(query, Id);
// Fall back to per-item query for LinkedChildren items (BoxSets, Playlists)
// or when no batch data is available
var query = new InternalItemsQuery(user);
if (LinkedChildren.Length > 0)
{
(playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCountFromLinkedChildren(query, Id);
}
else
{
(playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCount(query, Id);
}
}
if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))

View File

@@ -158,25 +158,25 @@ namespace MediaBrowser.Controller.Entities.Movies
return base.IsVisible(user, skipAllowedTagsCheck);
}
if (base.IsVisible(user, skipAllowedTagsCheck))
if (!IsVisibleViaTags(user, skipAllowedTagsCheck))
{
if (LinkedChildren.Length == 0)
{
return true;
}
var userLibraryFolderIds = GetLibraryFolderIds(user);
var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
if (libraryFolderIds.Length == 0)
{
return true;
}
return userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i));
return false;
}
return false;
if (LinkedChildren.Length == 0)
{
return true;
}
var userLibraryFolderIds = GetLibraryFolderIds(user);
var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
if (libraryFolderIds.Length == 0)
{
return true;
}
return userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i));
}
public override bool IsVisibleStandalone(User user)

View File

@@ -697,6 +697,15 @@ namespace MediaBrowser.Controller.Library
/// <returns>Dictionary mapping parent ID to child count.</returns>
Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items.
/// Avoids N+1 queries when building DTOs for lists of folder items.
/// </summary>
/// <param name="folderIds">The list of folder item IDs.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns>
Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user);
/// <summary>
/// Configures the query with user access settings including TopParentIds for library access.
/// Call this before passing a query to methods that need user access filtering.

View File

@@ -188,6 +188,16 @@ public interface IItemRepository
/// <returns>A tuple containing (Played count, Total count).</returns>
(int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items using the AncestorIds table.
/// This avoids N+1 queries when building DTOs for lists of folder items (Series, Seasons, etc.).
/// Applies user access filtering (parental controls, tags).
/// </summary>
/// <param name="folderIds">The list of folder item IDs to get counts for.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns>
Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user);
/// <summary>
/// Gets the IDs of linked children for the specified parent.
/// </summary>