fix(userdata): read user data through to the database
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

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
This commit is contained in:
2026-09-20 23:51:29 +10:00
parent ec581b5e5f
commit a7919b9bac
14 changed files with 470 additions and 136 deletions
@@ -449,19 +449,26 @@ namespace MediaBrowser.Controller.Entities
IUserDataManager userDataManager,
ILibraryManager libraryManager)
{
var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager));
var itemList = items as IReadOnlyList<BaseItem> ?? items.ToList();
// The user data checks below run per item, so read them all in one query up front.
var userDataBatch = user is not null && RequiresUserData(query)
? userDataManager.GetUserDataBatch(itemList, user)
: null;
var filtered = itemList.Where(i => Filter(i, user, query, userDataManager, libraryManager, userDataBatch));
if (query.IsPlayed.HasValue && user is not null)
{
var itemList = filtered.ToList();
var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList();
var filteredList = filtered.ToList();
var folderIds = filteredList.OfType<Folder>().Select(f => f.Id).ToList();
if (folderIds.Count > 0)
{
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
var isPlayedValue = query.IsPlayed.Value;
return itemList.Where(item =>
return filteredList.Where(item =>
{
if (item is Folder)
{
@@ -473,7 +480,7 @@ namespace MediaBrowser.Controller.Entities
});
}
return itemList;
return filteredList;
}
return filtered;
@@ -515,12 +522,29 @@ namespace MediaBrowser.Controller.Entities
itemsArray);
}
private static bool RequiresUserData(InternalItemsQuery query)
=> query.IsLiked.HasValue
|| query.IsFavoriteOrLiked.HasValue
|| query.IsFavorite.HasValue
|| query.IsResumable.HasValue
|| query.IsPlayed.HasValue;
private static UserItemData GetUserData(
IUserDataManager userDataManager,
User user,
BaseItem item,
Dictionary<Guid, UserItemData> userDataBatch)
=> userDataBatch is not null && userDataBatch.TryGetValue(item.Id, out var userData)
? userData
: userDataManager.GetUserData(user, item);
private static bool Filter(
BaseItem item,
User user,
InternalItemsQuery query,
IUserDataManager userDataManager,
ILibraryManager libraryManager)
ILibraryManager libraryManager,
Dictionary<Guid, UserItemData> userDataBatch)
{
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
{
@@ -568,7 +592,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsLiked.HasValue)
{
userData = userDataManager.GetUserData(user, item);
userData = GetUserData(userDataManager, user, item, userDataBatch);
if (!userData.Likes.HasValue || userData.Likes != query.IsLiked.Value)
{
return false;
@@ -577,7 +601,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsFavoriteOrLiked.HasValue)
{
userData ??= userDataManager.GetUserData(user, item);
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
var isFavoriteOrLiked = userData.IsFavorite || (userData.Likes ?? false);
if (isFavoriteOrLiked != query.IsFavoriteOrLiked.Value)
@@ -588,7 +612,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsFavorite.HasValue)
{
userData ??= userDataManager.GetUserData(user, item);
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
if (userData.IsFavorite != query.IsFavorite.Value)
{
return false;
@@ -597,7 +621,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsResumable.HasValue)
{
userData ??= userDataManager.GetUserData(user, item);
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
var isResumable = userData.PlaybackPositionTicks > 0;
if (isResumable != query.IsResumable.Value)
@@ -612,7 +636,7 @@ namespace MediaBrowser.Controller.Entities
// Folders are batch-filtered by the collection Filter() overload.
if (!item.IsFolder)
{
userData ??= userDataManager.GetUserData(user, item);
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
{
return false;
@@ -1,6 +1,9 @@
#nullable disable
using System;
using System.Collections.Generic;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
namespace MediaBrowser.Controller.Sorting
@@ -27,5 +30,11 @@ namespace MediaBrowser.Controller.Sorting
/// </summary>
/// <value>The user data repository.</value>
IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets or sets user data for the items being sorted, keyed by item id, read once up front.
/// </summary>
/// <value>The prefetched user data, or <c>null</c> when none was prefetched.</value>
IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
}
}
@@ -0,0 +1,28 @@
#nullable disable
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Sorting
{
/// <summary>
/// Helpers shared by the comparers that sort on user data.
/// </summary>
public static class UserBaseItemComparerExtensions
{
/// <summary>
/// Gets the user data for an item, preferring the batch the sort prefetched.
/// </summary>
/// <param name="comparer">The comparer.</param>
/// <param name="item">The item.</param>
/// <returns>The item's user data.</returns>
public static UserItemData GetUserData(this IUserBaseItemComparer comparer, BaseItem item)
{
if (comparer.PrefetchedUserData is not null && comparer.PrefetchedUserData.TryGetValue(item.Id, out var userData))
{
return userData;
}
return comparer.UserDataManager.GetUserData(comparer.User, item);
}
}
}