Merge pull request #17320 from TaterTechStudios/fix/item-correct-selector
Fix: Fetch the correct row matching the most up to date file
This commit is contained in:
@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
else
|
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)
|
if (userData is not null)
|
||||||
{
|
{
|
||||||
result[item.Id] = userData;
|
result[item.Id] = userData;
|
||||||
@@ -211,37 +212,32 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return result;
|
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 allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
||||||
var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
|
using var context = _repository.CreateDbContext();
|
||||||
if (allKeys.Count > 0)
|
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();
|
UserItemData userData;
|
||||||
var userDataArray = context.UserData
|
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
||||||
.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;
|
userData = Map(ResolveUserDataRow(item, itemUserData)!);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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;
|
return result;
|
||||||
@@ -356,12 +352,40 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemData? GetUserData(User user, BaseItem item)
|
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],
|
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 />
|
/// <inheritdoc />
|
||||||
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
|
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
|
||||||
=> GetUserDataDto(item, null, user, new DtoOptions());
|
=> GetUserDataDto(item, null, user, new DtoOptions());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user