Files
jellyfin-ha-src/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
T
brandon d6da6906a4 Batch people lookups when building item DTOs
GetBaseItemDtos already batch fetches user data, child counts, played counts
and artists before its per item loop, but AttachPeople still ran one GetPeople
query per item. Rendering a page of items (for example a large playlist) fired
one extra query per row.

Add GetPeopleByItems to IPeopleRepository, which reads every requested item in a
single query over the people mapping table and returns full PersonInfo (role,
type and sort order) grouped by item id. GetBaseItemDtos prefetches this once
when the People field is requested and passes it into AttachPeople, which reads
from the batch instead of querying per item. The single item GetBaseItemDto path
keeps its existing per item behaviour when no batch is supplied.

Adds a DtoService test asserting people resolve from the batch and the per item
GetPeople is never called.
2026-08-07 11:59:17 -04:00

248 lines
9.1 KiB
C#

using System;
using System.Collections.Generic;
using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums;
using MediaBrowser.Common;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Dto;
public class DtoServiceImageInheritanceTests
{
[Fact]
public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage()
{
var displayParent = new PlaylistsFolder
{
Id = Guid.NewGuid(),
ImageInfos =
[
new ItemImageInfo
{
Type = ImageType.Primary,
Path = "/images/playlists-custom.jpg",
DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
}
]
};
var userView = new UserView
{
Id = Guid.NewGuid(),
ViewType = CollectionType.playlists,
DisplayParentId = displayParent.Id,
ImageInfos =
[
new ItemImageInfo
{
Type = ImageType.Primary,
Path = "/images/generated.png",
DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
}
]
};
var dtoService = BuildDtoService(displayParent);
var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
Assert.NotNull(dto.ParentPrimaryImageItemId);
Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag);
Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary));
}
[Fact]
public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage()
{
var displayParent = new PlaylistsFolder
{
Id = Guid.NewGuid(),
ImageInfos = []
};
var userView = new UserView
{
Id = Guid.NewGuid(),
ViewType = CollectionType.playlists,
DisplayParentId = displayParent.Id,
ImageInfos =
[
new ItemImageInfo
{
Type = ImageType.Primary,
Path = "/images/generated.png",
DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
}
]
};
var dtoService = BuildDtoService(displayParent);
var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
Assert.Null(dto.ParentPrimaryImageItemId);
Assert.Null(dto.ParentPrimaryImageTag);
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
}
[Fact]
public void GetBaseItemDtos_MusicAlbums_ResolveInheritedThumbFromArtistBatch_WithoutPerAlbumLookup()
{
var artist = new MusicArtist
{
Id = Guid.NewGuid(),
Name = "Some Artist",
ImageInfos =
[
new ItemImageInfo
{
Type = ImageType.Thumb,
Path = "/images/artist-thumb.jpg",
DateModified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
}
]
};
static MusicAlbum MakeAlbum() => new MusicAlbum
{
Id = Guid.NewGuid(),
Name = "Album",
AlbumArtists = ["Some Artist"],
ImageInfos = []
};
var libraryManager = new Mock<ILibraryManager>();
// DtoService resolves every album-artist name in ONE batch (GetArtists). The album's inherited
// Thumb/Backdrop images must come from that batch, not a per-album GetArtist/GetItemList lookup
// (the N+1). GetArtist is intentionally left unset: a regression to the per-album path would
// resolve no artist and fail the assertions below.
libraryManager
.Setup(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()))
.Returns(new Dictionary<string, MusicArtist[]>(StringComparer.OrdinalIgnoreCase)
{
["Some Artist"] = [artist]
});
var dtoService = BuildDtoService(libraryManager);
var dtos = dtoService.GetBaseItemDtos([MakeAlbum(), MakeAlbum()], new DtoOptions(false));
Assert.Equal(2, dtos.Count);
foreach (var dto in dtos)
{
Assert.Equal(artist.Id, dto.ParentThumbItemId);
Assert.Equal("/images/artist-thumb.jpg", dto.ParentThumbImageTag);
}
// The artist lookup is batched once for the whole set, never once per album.
libraryManager.Verify(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()), Times.Once);
libraryManager.Verify(x => x.GetArtist(It.IsAny<string>(), It.IsAny<DtoOptions>()), Times.Never);
}
[Fact]
public void GetBaseItemDtos_Items_ResolvePeopleFromBatch_WithoutPerItemLookup()
{
static MusicAlbum MakeAlbum() => new MusicAlbum
{
Id = Guid.NewGuid(),
Name = "Album",
ImageInfos = []
};
var albumOne = MakeAlbum();
var albumTwo = MakeAlbum();
var libraryManager = new Mock<ILibraryManager>();
// DtoService resolves people for every item in ONE batch (GetPeopleByItems) before the
// per-item loop. A regression to the per-item path would call GetPeople(BaseItem) once per
// item (the N+1); it is intentionally left unset so such a regression fails here.
libraryManager
.Setup(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()))
.Returns(new Dictionary<Guid, IReadOnlyList<PersonInfo>>
{
[albumOne.Id] = [new PersonInfo { ItemId = albumOne.Id, Name = "Some Actor", Type = PersonKind.Actor }],
[albumTwo.Id] = [new PersonInfo { ItemId = albumTwo.Id, Name = "Some Actor", Type = PersonKind.Actor }]
});
// AttachPeople still resolves each distinct name to its Person entity to attach images.
libraryManager
.Setup(x => x.GetPerson("Some Actor"))
.Returns(new Person { Id = Guid.NewGuid(), Name = "Some Actor" });
var dtoService = BuildDtoService(libraryManager);
var options = new DtoOptions(false) { Fields = [ItemFields.People] };
var dtos = dtoService.GetBaseItemDtos([albumOne, albumTwo], options);
Assert.Equal(2, dtos.Count);
foreach (var dto in dtos)
{
Assert.NotNull(dto.People);
Assert.Single(dto.People);
Assert.Equal("Some Actor", dto.People[0].Name);
}
// People are batched once for the whole set, never once per item.
libraryManager.Verify(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()), Times.Once);
libraryManager.Verify(x => x.GetPeople(It.IsAny<BaseItem>()), Times.Never);
}
private static DtoService BuildDtoService(BaseItem displayParent)
{
var libraryManager = new Mock<ILibraryManager>();
libraryManager
.Setup(x => x.GetItemById(displayParent.Id))
.Returns(displayParent);
return BuildDtoService(libraryManager);
}
private static DtoService BuildDtoService(Mock<ILibraryManager> libraryManager)
{
var userDataManager = new Mock<IUserDataManager>();
var imageProcessor = new Mock<IImageProcessor>();
var providerManager = new Mock<IProviderManager>();
var recordingsManager = new Mock<IRecordingsManager>();
var appHost = new Mock<IApplicationHost>();
var mediaSourceManager = new Mock<IMediaSourceManager>();
var liveTvManager = new Mock<ILiveTvManager>();
var trickplayManager = new Mock<ITrickplayManager>();
var chapterManager = new Mock<IChapterManager>();
var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
imageProcessor
.Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
.Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
return new DtoService(
logger.Object,
libraryManager.Object,
userDataManager.Object,
imageProcessor.Object,
providerManager.Object,
recordingsManager.Object,
appHost.Object,
mediaSourceManager.Object,
new Lazy<ILiveTvManager>(() => liveTvManager.Object),
trickplayManager.Object,
chapterManager.Object);
}
}