#pragma warning disable RS0030 // Do not use banned APIs using System; using System.Collections.Generic; using System.Linq; using System.Threading; 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; /// /// 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; } /// 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(); 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) { ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(user); var result = new Dictionary(items.Count); if (items.Count == 0) { return result; } // Fetch rows by item alone so rows kept under keys from older metadata resolve the same // way as the single item path. var itemIds = items.Select(e => e.Id).Distinct().ToList(); using var context = _repository.CreateDbContext(); var userDataByItem = context.UserData .AsNoTracking() .Where(e => e.UserId.Equals(user.Id)) .WhereOneOrMany(itemIds, e => e.ItemId) .ToArray() .GroupBy(e => e.ItemId) .ToDictionary(g => g.Key, g => g.ToArray()); foreach (var item in items) { if (result.ContainsKey(item.Id)) { continue; } var row = userDataByItem.TryGetValue(item.Id, out var itemUserData) ? ResolveUserDataRow(item, itemUserData) : null; result[item.Id] = row is not null ? Map(row) : new UserItemData { Key = item.GetUserDataKeys().FirstOrDefault() ?? string.Empty }; } 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(); // Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data. // Linked alternates are already known in memory; only the local-alternate existence check // would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions), // so collect those ids and resolve them all in a single query below. List