Move GetUserDataBatch to use ResolveUserDataRow when item.UserData isn't preloaded

This commit is contained in:
Jordan Rushing
2026-07-15 16:21:42 -05:00
parent fcce108948
commit 3f96790904
2 changed files with 86 additions and 31 deletions
@@ -212,37 +212,32 @@ namespace Emby.Server.Implementations.Library
return result;
}
// Build a single query for all missing items
// Build a single query for all missing items. Fetch rows by item alone so rows kept
// under keys from older metadata resolve the same way as the in-memory path.
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
if (allKeys.Count > 0)
using var context = _repository.CreateDbContext();
var userDataArray = context.UserData
.AsNoTracking()
.Where(e => e.UserId.Equals(user.Id))
.WhereOneOrMany(allItemIds, e => e.ItemId)
.ToArray();
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
foreach (var (item, keys) in itemsNeedingQuery)
{
using var context = _repository.CreateDbContext();
var userDataArray = context.UserData
.AsNoTracking()
.Where(e => e.UserId.Equals(user.Id))
.WhereOneOrMany(allItemIds, e => e.ItemId)
.WhereOneOrMany(allKeys, e => e.CustomDataKey)
.ToArray();
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
foreach (var (item, keys) in itemsNeedingQuery)
UserItemData userData;
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
UserItemData userData;
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
}
else
{
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
}
result[item.Id] = userData;
var cacheKey = GetCacheKey(user.InternalId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
userData = Map(ResolveUserDataRow(item, itemUserData)!);
}
else
{
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
}
result[item.Id] = userData;
var cacheKey = GetCacheKey(user.InternalId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
}
return result;
@@ -3,35 +3,68 @@ using System.Collections.Generic;
using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
namespace Jellyfin.Server.Implementations.Tests.Library;
public class UserDataManagerTests
public sealed class UserDataManagerTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly UserDataManager _userDataManager;
private readonly User _user;
public UserDataManagerTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
var config = new Mock<IServerConfigurationManager>();
config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
var repository = Mock.Of<IDbContextFactory<JellyfinDbContext>>();
_userDataManager = new UserDataManager(config.Object, repository);
_userDataManager = new UserDataManager(config.Object, factory.Object);
_user = new User("user", "auth-provider", "reset-provider")
{
Id = Guid.NewGuid()
};
}
public void Dispose()
{
_connection.Dispose();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
private AudioBook CreateAudioBook()
{
// GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"]
@@ -146,4 +179,31 @@ public class UserDataManagerTests
Assert.NotNull(userData);
Assert.Equal(222, userData.PlaybackPositionTicks);
}
[Fact]
public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
{
// no preloaded navigation data, so the batch takes the database fallback
var fossilItem = CreateAudioBook();
var retiredItem = CreateAudioBook();
using (var ctx = CreateDbContext())
{
ctx.Users.Add(_user);
ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
// the stale id-key row is inserted first so selection by row order would return it
ctx.UserData.AddRange(
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
ctx.SaveChanges();
}
var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
}
}