a7919b9bac
The user data cache and the item's in-memory rows were both filled once per replica and never invalidated, so a pod serving a playback tick read its own stale resume position and wrote it back over the position another pod had just saved, silently losing resume points, played state, favourites and ratings. - drop the user item data cache - read single and batched user data from the database on every query - prefetch user data for in-memory sorts and filters so each stays one query - cover the lost update against real PostgreSQL with two manager instances
222 lines
6.9 KiB
C#
222 lines
6.9 KiB
C#
using System;
|
|
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()
|
|
};
|
|
|
|
using (var ctx = CreateDbContext())
|
|
{
|
|
ctx.Users.Add(_user);
|
|
ctx.SaveChanges();
|
|
}
|
|
}
|
|
|
|
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 void Seed(AudioBook item, params UserData[] rows)
|
|
{
|
|
using var ctx = CreateDbContext();
|
|
ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = typeof(AudioBook).FullName! });
|
|
ctx.UserData.AddRange(rows);
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
private User CreateOtherUser()
|
|
{
|
|
var user = new User("other", "auth-provider", "reset-provider") { Id = Guid.NewGuid() };
|
|
using var ctx = CreateDbContext();
|
|
ctx.Users.Add(user);
|
|
ctx.SaveChanges();
|
|
return user;
|
|
}
|
|
|
|
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
|
|
Seed(
|
|
item,
|
|
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];
|
|
|
|
Seed(
|
|
item,
|
|
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();
|
|
|
|
Seed(item, 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();
|
|
Seed(item);
|
|
|
|
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 = CreateOtherUser().Id;
|
|
|
|
Seed(item, 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()
|
|
{
|
|
var fossilItem = CreateAudioBook();
|
|
var retiredItem = CreateAudioBook();
|
|
|
|
// the stale id-key row is inserted first so selection by row order would return it
|
|
Seed(
|
|
fossilItem,
|
|
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
|
|
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222));
|
|
Seed(retiredItem, CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
|
|
|
|
var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
|
|
|
|
Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
|
|
Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetUserData_NullUser_ThrowsArgumentNullException()
|
|
{
|
|
var item = CreateAudioBook();
|
|
Assert.Throws<ArgumentNullException>(() => _userDataManager.GetUserData(null!, item));
|
|
}
|
|
}
|