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
@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
@@ -205,6 +206,43 @@ public class DtoServiceImageInheritanceTests
libraryManager.Verify(x => x.GetPeople(It.IsAny<BaseItem>()), Times.Never);
}
[Fact]
public void GetBaseItemDtos_Videos_ResolveMediaSourceCountFromBatch_WithoutPerItemLookup()
{
static Movie MakeMovie() => new Movie
{
Id = Guid.NewGuid(),
Name = "Movie",
ImageInfos = []
};
var movieOne = MakeMovie();
var movieTwo = MakeMovie();
var libraryManager = new Mock<ILibraryManager>();
// DtoService detects which videos own alternate versions in ONE batch
// (GetItemsWithAlternateVersions) before the per-item loop. Videos absent from that set have a
// single media source, so the per-item GetLinkedAlternateVersions/GetLocalAlternateVersionIds
// queries (the N+1) must be skipped entirely. Here neither movie has alternate versions.
libraryManager
.Setup(x => x.GetItemsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()))
.Returns(new HashSet<Guid>());
var dtoService = BuildDtoService(libraryManager);
var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] };
var dtos = dtoService.GetBaseItemDtos([movieOne, movieTwo], options);
Assert.Equal(2, dtos.Count);
// The alternate-version check is batched once for the whole set, and the per-item lookups are
// never reached because the batch already ruled out alternate versions.
libraryManager.Verify(x => x.GetItemsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once);
libraryManager.Verify(x => x.GetLinkedAlternateVersions(It.IsAny<Video>()), Times.Never);
libraryManager.Verify(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()), Times.Never);
}
private static DtoService BuildDtoService(BaseItem displayParent)
{
var libraryManager = new Mock<ILibraryManager>();
@@ -231,6 +269,10 @@ public class DtoServiceImageInheritanceTests
.Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
.Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
// Video.IsActiveRecording() dereferences this static during DTO building.
Video.RecordingsManager = recordingsManager.Object;
BaseItem.LibraryManager = libraryManager.Object;
return new DtoService(
logger.Object,
libraryManager.Object,