diff options
| -rw-r--r-- | Emby.Server.Implementations/Library/UserDataManager.cs | 84 | ||||
| -rw-r--r-- | tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs | 209 |
2 files changed, 263 insertions, 30 deletions
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 19b42a3a11..f5c41e5670 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library } else { - var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault(); + var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + var userData = userDataRow is not null ? Map(userDataRow) : null; if (userData is not null) { result[item.Id] = userData; @@ -211,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) - .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) + 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) + { + 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; @@ -356,12 +352,40 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public UserItemData? GetUserData(User user, BaseItem item) { - return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() + var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + return row is not null ? Map(row) : new UserItemData() { Key = item.GetUserDataKeys()[0], }; } + /// <summary> + /// Picks the row matching the item's current user data keys, in key order, so rows left behind + /// under keys from older metadata don't take priority over the rows the write path updates. + /// </summary> + /// <param name="item">The item whose keys to match.</param> + /// <param name="rows">The candidate user data rows for a single user.</param> + /// <returns>The best matching row, or <c>null</c> when there are none.</returns> + private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows) + { + var candidates = rows?.ToList(); + if (candidates is null || candidates.Count == 0) + { + return null; + } + + foreach (var key in item.GetUserDataKeys()) + { + var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + } + + return candidates[0]; + } + /// <inheritdoc /> public UserItemDataDto? GetUserDataDto(BaseItem item, User user) => GetUserDataDto(item, null, user, new DtoOptions()); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs new file mode 100644 index 0000000000..bd14ca008d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -0,0 +1,209 @@ +using System; +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 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()); + + _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>"] + return new AudioBook + { + Id = Guid.NewGuid(), + Name = "Book Title", + Album = "Series", + AlbumArtists = new[] { "Author" }, + IndexNumber = 1 + }; + } + + private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks) + { + return new UserData + { + ItemId = item.Id, + Item = null, + UserId = _user.Id, + User = null, + CustomDataKey = key, + PlaybackPositionTicks = positionTicks + }; + } + + [Fact] + public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + // the retired-key row comes first to ensure selection is by key, not row order + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(currentKey, userData.Key); + Assert.Equal(222, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow() + { + var item = CreateAudioBook(); + var idKey = item.GetUserDataKeys()[1]; + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, idKey, 333) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(idKey, userData.Key); + Assert.Equal(333, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow() + { + var item = CreateAudioBook(); + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(111, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey() + { + var item = CreateAudioBook(); + item.UserData = new List<UserData>(); + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(item.GetUserDataKeys()[0], userData.Key); + Assert.Equal(0, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_RowsForOtherUsers_AreIgnored() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + var otherUserRow = CreateUserDataRow(item, currentKey, 999); + otherUserRow.UserId = Guid.NewGuid(); + + item.UserData = new List<UserData> + { + otherUserRow, + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + 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); + } +} |
