Files
unkin-agent 1c59e6afcb
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
perf(nextup): batch the user data reads the next episode selection makes
Next Up walked every series in an unpaginated loop and read user data one
episode at a time, so the Home screen row cost two queries per series once the
cache was gone.

- read the played state of every candidate episode in one query
- read the versions the resume check and the last played date need in one query each
- default PrefetchedUserData on IUserBaseItemComparer so plugin comparers still compile
- cover the bounded query count and the plugin comparer with tests
- share one database across the replica tests
2026-09-21 00:36:39 +10:00

145 lines
5.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using AutoFixture;
using AutoFixture.AutoMoq;
using Emby.Naming.Common;
using Emby.Server.Implementations.Library;
using Emby.Server.Implementations.Sorting;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.IO;
using Moq;
using Xunit;
using BaseItem = MediaBrowser.Controller.Entities.BaseItem;
namespace Jellyfin.Server.Implementations.Tests.Library;
public class LibraryManagerSortTests
{
[Fact]
public void Sort_UserDependentKey_NullUser_ThrowsArgumentException()
{
var libraryManager = CreateLibraryManager(
new IBaseItemComparer[] { new PlayCountComparer(), new SortNameComparer() });
BaseItem[] items =
{
new Audio { Name = "Zulu", SortName = "Zulu", Id = Guid.NewGuid() },
new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() },
};
Assert.Throws<ArgumentException>(() => libraryManager.Sort(
items,
user: null,
new[] { (ItemSortBy.PlayCount, SortOrder.Descending) }).ToArray());
}
[Fact]
public void Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName()
{
var libraryManager = CreateLibraryManager(
new IBaseItemComparer[] { new DateLastMediaAddedComparer(), new SortNameComparer() });
BaseItem[] items =
{
MakeFolder("Alpha", new DateTime(2026, 1, 1)),
MakeFolder("Mike", new DateTime(2025, 1, 1)),
MakeFolder("Zulu", new DateTime(2024, 1, 1))
};
var sorted = libraryManager.Sort(
items,
user: null,
new[] { (ItemSortBy.DateLastContentAdded, SortOrder.Descending) }).ToArray();
Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name));
}
[Fact]
public void Sort_ComparerThatIgnoresPrefetchedUserData_StillSortsFromLiveReads()
{
var alpha = new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() };
var zulu = new Audio { Name = "Zulu", SortName = "Zulu", Id = Guid.NewGuid() };
var playCounts = new Dictionary<Guid, int> { [alpha.Id] = 1, [zulu.Id] = 9 };
var userDataManager = new Mock<IUserDataManager>();
userDataManager
.Setup(u => u.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
.Returns<User, BaseItem>((_, item) => new UserItemData { Key = item.Id.ToString("N"), PlayCount = playCounts[item.Id] });
userDataManager
.Setup(u => u.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()))
.Returns(new Dictionary<Guid, UserItemData>());
var libraryManager = CreateLibraryManager(
new IBaseItemComparer[] { new PluginPlayCountComparer() },
userDataManager);
var sorted = libraryManager.Sort(
new BaseItem[] { alpha, zulu },
new User("sorter", "provider", "provider"),
new[] { (ItemSortBy.PlayCount, SortOrder.Descending) }).ToArray();
Assert.Equal(new[] { "Zulu", "Alpha" }, sorted.Select(i => i.Name));
userDataManager.Verify(u => u.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()), Times.AtLeastOnce);
}
private static Folder MakeFolder(string name, DateTime dateLastMediaAdded)
=> new() { Name = name, Id = Guid.NewGuid(), DateLastMediaAdded = dateLastMediaAdded };
private static Emby.Server.Implementations.Library.LibraryManager CreateLibraryManager(
IReadOnlyCollection<IBaseItemComparer> comparers,
Mock<IUserDataManager>? userDataManager = null)
{
var fixture = new Fixture().Customize(new AutoMoqCustomization());
fixture.Register(() => new NamingOptions());
if (userDataManager is not null)
{
fixture.Inject(userDataManager.Object);
}
var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
BaseItem.ConfigurationManager ??= configMock.Object;
var itemRepository = fixture.Freeze<Mock<IItemRepository>>();
itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
var fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
return fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts(
fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
fixture.Create<IEnumerable<IItemResolver>>(),
fixture.Create<IEnumerable<IIntroProvider>>(),
comparers,
fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
.Create();
}
/// <summary>
/// A comparer of the shape a third-party plugin ships: it implements
/// <see cref="IUserBaseItemComparer"/> without ever mentioning PrefetchedUserData.
/// </summary>
public sealed class PluginPlayCountComparer : IUserBaseItemComparer
{
public User User { get; set; } = null!;
public IUserManager UserManager { get; set; } = null!;
public IUserDataManager UserDataManager { get; set; } = null!;
public ItemSortBy Type => ItemSortBy.PlayCount;
public int Compare(BaseItem? x, BaseItem? y)
=> UserDataManager.GetUserData(User, x!)!.PlayCount.CompareTo(UserDataManager.GetUserData(User, y!)!.PlayCount);
}
}