#pragma warning disable RS0030 // Do not use banned APIs using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using BitFaster.Caching.Lru; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using Microsoft.EntityFrameworkCore; using AudioBook = MediaBrowser.Controller.Entities.AudioBook; using Book = MediaBrowser.Controller.Entities.Book; namespace Emby.Server.Implementations.Library { /// /// Class UserDataManager. /// public class UserDataManager : IUserDataManager { private readonly IServerConfigurationManager _config; private readonly IDbContextFactory _repository; private readonly FastConcurrentLru _cache; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. public UserDataManager( IServerConfigurationManager config, IDbContextFactory repository) { _config = config; _repository = repository; _cache = new FastConcurrentLru(Environment.ProcessorCount, _config.Configuration.CacheSize, StringComparer.OrdinalIgnoreCase); } /// public event EventHandler? UserDataSaved; /// public void SaveUserData(User user, BaseItem item, UserItemData userData, UserDataSaveReason reason, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userData); ArgumentNullException.ThrowIfNull(item); cancellationToken.ThrowIfCancellationRequested(); var keys = item.GetUserDataKeys(); using var dbContext = _repository.CreateDbContext(); using var transaction = dbContext.Database.BeginTransaction(); foreach (var key in keys) { userData.Key = key; var userDataEntry = Map(userData, user.Id, item.Id); if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey)) { dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified; } else { dbContext.UserData.Add(userDataEntry); } } dbContext.SaveChanges(); transaction.Commit(); var userId = user.InternalId; var cacheKey = GetCacheKey(userId, item.Id); _cache.AddOrUpdate(cacheKey, userData); item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata UserDataSaved?.Invoke(this, new UserDataSaveEventArgs { Keys = keys, UserData = userData, SaveReason = reason, UserId = user.Id, Item = item }); } /// public void SaveUserData(User user, BaseItem item, UpdateUserItemDataDto userDataDto, UserDataSaveReason reason) { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(item); ArgumentNullException.ThrowIfNull(userDataDto); var userData = GetUserData(user, item) ?? throw new InvalidOperationException("UserData should not be null."); if (userDataDto.PlaybackPositionTicks.HasValue) { userData.PlaybackPositionTicks = userDataDto.PlaybackPositionTicks.Value; } if (userDataDto.PlayCount.HasValue) { userData.PlayCount = userDataDto.PlayCount.Value; } if (userDataDto.IsFavorite.HasValue) { userData.IsFavorite = userDataDto.IsFavorite.Value; } if (userDataDto.Likes.HasValue) { userData.Likes = userDataDto.Likes.Value; } if (userDataDto.Played.HasValue) { userData.Played = userDataDto.Played.Value; } if (userDataDto.LastPlayedDate.HasValue) { userData.LastPlayedDate = userDataDto.LastPlayedDate.Value; } if (userDataDto.Rating.HasValue) { userData.Rating = userDataDto.Rating.Value; } SaveUserData(user, item, userData, reason, CancellationToken.None); } private UserData Map(UserItemData dto, Guid userId, Guid itemId) { return new UserData() { ItemId = itemId, CustomDataKey = dto.Key, Item = null, User = null, AudioStreamIndex = dto.AudioStreamIndex, IsFavorite = dto.IsFavorite, LastPlayedDate = dto.LastPlayedDate, Likes = dto.Likes, PlaybackPositionTicks = dto.PlaybackPositionTicks, PlayCount = dto.PlayCount, Played = dto.Played, Rating = dto.Rating, UserId = userId, SubtitleStreamIndex = dto.SubtitleStreamIndex, }; } private static UserItemData Map(UserData dto) { return new UserItemData() { Key = dto.CustomDataKey!, AudioStreamIndex = dto.AudioStreamIndex, IsFavorite = dto.IsFavorite, LastPlayedDate = dto.LastPlayedDate, Likes = dto.Likes, PlaybackPositionTicks = dto.PlaybackPositionTicks, PlayCount = dto.PlayCount, Played = dto.Played, Rating = dto.Rating, SubtitleStreamIndex = dto.SubtitleStreamIndex, }; } /// public Dictionary GetUserDataBatch(IReadOnlyList items, User user) { var result = new Dictionary(items.Count); var itemsNeedingQuery = new List<(BaseItem Item, List Keys)>(); foreach (var item in items) { var cacheKey = GetCacheKey(user.InternalId, item.Id); if (_cache.TryGet(cacheKey, out var cachedData)) { result[item.Id] = cachedData; } else { var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault(); if (userData is not null) { result[item.Id] = userData; _cache.AddOrUpdate(cacheKey, userData); } else { var keys = item.GetUserDataKeys(); itemsNeedingQuery.Add((item, keys)); } } } if (itemsNeedingQuery.Count == 0) { return result; } // Build a single query for all missing items 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) { 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); } } return result; } /// public VersionResumeData? GetResumeUserData(User user, BaseItem item) { return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id); } /// public IReadOnlyDictionary GetResumeUserDataBatch(IReadOnlyList items, User user) { ArgumentNullException.ThrowIfNull(user); var result = new Dictionary(); List<(Guid PrimaryId, IReadOnlyList