Batch alternate version detection in DtoService to remove MediaSourceCount N+1

Browsing a page of videos with the MediaSourceCount field ran one alternate
version query per item, each opening a fresh DbContext. On a large library that
turned a single page into hundreds of sequential round trips and made the Items
endpoint take tens of seconds while holding a request thread the whole time.

Detect which videos own alternate versions once per page with a single query,
mirroring the existing people batch. Videos absent from that set have a single
media source, so the per item lookups are skipped for the common case. Behavior
is unchanged: a video with no alternates already resolved to a count of one.

Adds a regression test asserting the count resolves from the batch and the per
item lookups are never called.
This commit is contained in:
brandon
2026-08-07 22:51:45 -04:00
parent 70ffd25b50
commit c091ffdc6b
6 changed files with 123 additions and 12 deletions
+37 -12
View File
@@ -253,6 +253,18 @@ namespace Emby.Server.Implementations.Dto
}
}
// Batch-detect which videos own alternate versions to avoid the per-item alternate-version
// queries in MediaSourceCount. Videos absent from this set have a single media source.
IReadOnlySet<Guid>? alternateVersionItemIds = null;
if (options.ContainsField(ItemFields.MediaSourceCount))
{
var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList();
if (versionItemIds.Count > 0)
{
alternateVersionItemIds = _libraryManager.GetItemsWithAlternateVersions(versionItemIds);
}
}
for (int index = 0; index < accessibleItems.Count; index++)
{
var item = accessibleItems[index];
@@ -267,7 +279,8 @@ namespace Emby.Server.Implementations.Dto
playedCountBatch,
artistsBatch,
resumeDataBatch?.GetValueOrDefault(item.Id),
peopleBatch);
peopleBatch,
alternateVersionItemIds);
if (item is LiveTvChannel tvChannel)
{
@@ -330,7 +343,8 @@ namespace Emby.Server.Implementations.Dto
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
VersionResumeData? resumeData = null,
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null)
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null,
IReadOnlySet<Guid>? alternateVersionItemIds = null)
{
var dto = new BaseItemDto
{
@@ -399,7 +413,7 @@ namespace Emby.Server.Implementations.Dto
AttachStudios(dto, item);
}
AttachBasicFields(dto, item, owner, options, artistsBatch, user);
AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds);
if (options.ContainsField(ItemFields.CanDelete))
{
@@ -984,7 +998,8 @@ namespace Emby.Server.Implementations.Dto
/// <param name="options">The options.</param>
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
/// <param name="user">The user, for per-user values such as the accessible media source count.</param>
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null)
/// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param>
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null)
{
if (options.ContainsField(ItemFields.DateCreated))
{
@@ -1298,15 +1313,25 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.MediaSourceCount))
{
// Match the per-user filtering of the media sources: versions the user cannot
// access are not selectable, so they must not count towards the badge either.
var mediaSourceCount = user is null
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
? video.MediaSourceCount
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
if (mediaSourceCount != 1)
// A video with no primary version and no alternate versions always has a single
// media source. When the batch has already determined this item owns no alternate
// versions, skip the per-item alternate-version queries entirely (the common case).
var hasNoAlternateVersions = alternateVersionItemIds is not null
&& !video.PrimaryVersionId.HasValue
&& !alternateVersionItemIds.Contains(video.Id);
if (!hasNoAlternateVersions)
{
dto.MediaSourceCount = mediaSourceCount;
// Match the per-user filtering of the media sources: versions the user cannot
// access are not selectable, so they must not count towards the badge either.
var mediaSourceCount = user is null
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
? video.MediaSourceCount
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
if (mediaSourceCount != 1)
{
dto.MediaSourceCount = mediaSourceCount;
}
}
}
@@ -2234,6 +2234,12 @@ namespace Emby.Server.Implementations.Library
return [];
}
/// <inheritdoc />
public IReadOnlySet<Guid> GetItemsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
{
return _linkedChildrenService.GetItemsWithAlternateVersions(itemIds);
}
/// <inheritdoc />
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
{