UserDataManager.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. #pragma warning disable RS0030 // Do not use banned APIs
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Globalization;
  5. using System.Linq;
  6. using System.Threading;
  7. using BitFaster.Caching.Lru;
  8. using Jellyfin.Database.Implementations;
  9. using Jellyfin.Database.Implementations.Entities;
  10. using MediaBrowser.Controller.Configuration;
  11. using MediaBrowser.Controller.Dto;
  12. using MediaBrowser.Controller.Entities;
  13. using MediaBrowser.Controller.Library;
  14. using MediaBrowser.Model.Dto;
  15. using MediaBrowser.Model.Entities;
  16. using Microsoft.EntityFrameworkCore;
  17. using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
  18. using Book = MediaBrowser.Controller.Entities.Book;
  19. namespace Emby.Server.Implementations.Library
  20. {
  21. /// <summary>
  22. /// Class UserDataManager.
  23. /// </summary>
  24. public class UserDataManager : IUserDataManager
  25. {
  26. private readonly IServerConfigurationManager _config;
  27. private readonly IDbContextFactory<JellyfinDbContext> _repository;
  28. private readonly FastConcurrentLru<string, UserItemData> _cache;
  29. /// <summary>
  30. /// Initializes a new instance of the <see cref="UserDataManager"/> class.
  31. /// </summary>
  32. /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
  33. /// <param name="repository">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
  34. public UserDataManager(
  35. IServerConfigurationManager config,
  36. IDbContextFactory<JellyfinDbContext> repository)
  37. {
  38. _config = config;
  39. _repository = repository;
  40. _cache = new FastConcurrentLru<string, UserItemData>(Environment.ProcessorCount, _config.Configuration.CacheSize, StringComparer.OrdinalIgnoreCase);
  41. }
  42. /// <inheritdoc />
  43. public event EventHandler<UserDataSaveEventArgs>? UserDataSaved;
  44. /// <inheritdoc />
  45. public void SaveUserData(User user, BaseItem item, UserItemData userData, UserDataSaveReason reason, CancellationToken cancellationToken)
  46. {
  47. ArgumentNullException.ThrowIfNull(userData);
  48. ArgumentNullException.ThrowIfNull(item);
  49. cancellationToken.ThrowIfCancellationRequested();
  50. var keys = item.GetUserDataKeys();
  51. using var dbContext = _repository.CreateDbContext();
  52. using var transaction = dbContext.Database.BeginTransaction();
  53. foreach (var key in keys)
  54. {
  55. userData.Key = key;
  56. var userDataEntry = Map(userData, user.Id, item.Id);
  57. if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey))
  58. {
  59. dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified;
  60. }
  61. else
  62. {
  63. dbContext.UserData.Add(userDataEntry);
  64. }
  65. }
  66. dbContext.SaveChanges();
  67. transaction.Commit();
  68. var userId = user.InternalId;
  69. var cacheKey = GetCacheKey(userId, item.Id);
  70. _cache.AddOrUpdate(cacheKey, userData);
  71. UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
  72. {
  73. Keys = keys,
  74. UserData = userData,
  75. SaveReason = reason,
  76. UserId = user.Id,
  77. Item = item
  78. });
  79. }
  80. /// <inheritdoc />
  81. public void SaveUserData(User user, BaseItem item, UpdateUserItemDataDto userDataDto, UserDataSaveReason reason)
  82. {
  83. ArgumentNullException.ThrowIfNull(user);
  84. ArgumentNullException.ThrowIfNull(item);
  85. ArgumentNullException.ThrowIfNull(userDataDto);
  86. var userData = GetUserData(user, item) ?? throw new InvalidOperationException("UserData should not be null.");
  87. if (userDataDto.PlaybackPositionTicks.HasValue)
  88. {
  89. userData.PlaybackPositionTicks = userDataDto.PlaybackPositionTicks.Value;
  90. }
  91. if (userDataDto.PlayCount.HasValue)
  92. {
  93. userData.PlayCount = userDataDto.PlayCount.Value;
  94. }
  95. if (userDataDto.IsFavorite.HasValue)
  96. {
  97. userData.IsFavorite = userDataDto.IsFavorite.Value;
  98. }
  99. if (userDataDto.Likes.HasValue)
  100. {
  101. userData.Likes = userDataDto.Likes.Value;
  102. }
  103. if (userDataDto.Played.HasValue)
  104. {
  105. userData.Played = userDataDto.Played.Value;
  106. }
  107. if (userDataDto.LastPlayedDate.HasValue)
  108. {
  109. userData.LastPlayedDate = userDataDto.LastPlayedDate.Value;
  110. }
  111. if (userDataDto.Rating.HasValue)
  112. {
  113. userData.Rating = userDataDto.Rating.Value;
  114. }
  115. SaveUserData(user, item, userData, reason, CancellationToken.None);
  116. }
  117. private UserData Map(UserItemData dto, Guid userId, Guid itemId)
  118. {
  119. return new UserData()
  120. {
  121. ItemId = itemId,
  122. CustomDataKey = dto.Key,
  123. Item = null,
  124. User = null,
  125. AudioStreamIndex = dto.AudioStreamIndex,
  126. IsFavorite = dto.IsFavorite,
  127. LastPlayedDate = dto.LastPlayedDate,
  128. Likes = dto.Likes,
  129. PlaybackPositionTicks = dto.PlaybackPositionTicks,
  130. PlayCount = dto.PlayCount,
  131. Played = dto.Played,
  132. Rating = dto.Rating,
  133. UserId = userId,
  134. SubtitleStreamIndex = dto.SubtitleStreamIndex,
  135. };
  136. }
  137. private UserItemData Map(UserData dto)
  138. {
  139. return new UserItemData()
  140. {
  141. Key = dto.CustomDataKey!,
  142. AudioStreamIndex = dto.AudioStreamIndex,
  143. IsFavorite = dto.IsFavorite,
  144. LastPlayedDate = dto.LastPlayedDate,
  145. Likes = dto.Likes,
  146. PlaybackPositionTicks = dto.PlaybackPositionTicks,
  147. PlayCount = dto.PlayCount,
  148. Played = dto.Played,
  149. Rating = dto.Rating,
  150. SubtitleStreamIndex = dto.SubtitleStreamIndex,
  151. };
  152. }
  153. private UserItemData? GetUserData(User user, Guid itemId, List<string> keys)
  154. {
  155. var cacheKey = GetCacheKey(user.InternalId, itemId);
  156. if (_cache.TryGet(cacheKey, out var data))
  157. {
  158. return data;
  159. }
  160. data = GetUserDataInternal(user.Id, itemId, keys);
  161. if (data is null)
  162. {
  163. return new UserItemData()
  164. {
  165. Key = keys[0],
  166. };
  167. }
  168. return _cache.GetOrAdd(cacheKey, _ => data);
  169. }
  170. private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List<string> keys)
  171. {
  172. if (keys.Count == 0)
  173. {
  174. return null;
  175. }
  176. using var context = _repository.CreateDbContext();
  177. var userData = context.UserData.AsNoTracking().Where(e => e.ItemId == itemId && keys.Contains(e.CustomDataKey) && e.UserId.Equals(userId)).ToArray();
  178. if (userData.Length > 0)
  179. {
  180. var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N"));
  181. if (directDataReference is not null)
  182. {
  183. return Map(directDataReference);
  184. }
  185. return Map(userData.First());
  186. }
  187. return new UserItemData
  188. {
  189. Key = keys.Last()!
  190. };
  191. }
  192. /// <summary>
  193. /// Gets the internal key.
  194. /// </summary>
  195. /// <returns>System.String.</returns>
  196. private static string GetCacheKey(long internalUserId, Guid itemId)
  197. {
  198. return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N", CultureInfo.InvariantCulture);
  199. }
  200. /// <inheritdoc />
  201. public UserItemData? GetUserData(User user, BaseItem item)
  202. {
  203. return GetUserData(user, item.Id, item.GetUserDataKeys());
  204. }
  205. /// <inheritdoc />
  206. public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
  207. => GetUserDataDto(item, null, user, new DtoOptions());
  208. /// <inheritdoc />
  209. public UserItemDataDto? GetUserDataDto(BaseItem item, BaseItemDto? itemDto, User user, DtoOptions options)
  210. {
  211. var userData = GetUserData(user, item);
  212. if (userData is null)
  213. {
  214. return null;
  215. }
  216. var dto = GetUserItemDataDto(userData, item.Id);
  217. item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
  218. return dto;
  219. }
  220. /// <summary>
  221. /// Converts a UserItemData to a DTOUserItemData.
  222. /// </summary>
  223. /// <param name="data">The data.</param>
  224. /// <param name="itemId">The reference key to an Item.</param>
  225. /// <returns>DtoUserItemData.</returns>
  226. /// <exception cref="ArgumentNullException"><paramref name="data"/> is <c>null</c>.</exception>
  227. private UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId)
  228. {
  229. ArgumentNullException.ThrowIfNull(data);
  230. return new UserItemDataDto
  231. {
  232. IsFavorite = data.IsFavorite,
  233. Likes = data.Likes,
  234. PlaybackPositionTicks = data.PlaybackPositionTicks,
  235. PlayCount = data.PlayCount,
  236. Rating = data.Rating,
  237. Played = data.Played,
  238. LastPlayedDate = data.LastPlayedDate,
  239. ItemId = itemId,
  240. Key = data.Key
  241. };
  242. }
  243. /// <inheritdoc />
  244. public bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks)
  245. {
  246. var playedToCompletion = false;
  247. var runtimeTicks = item.GetRunTimeTicksForPlayState();
  248. var positionTicks = reportedPositionTicks ?? runtimeTicks;
  249. var hasRuntime = runtimeTicks > 0;
  250. // If a position has been reported, and if we know the duration
  251. if (positionTicks > 0 && hasRuntime && item is not AudioBook && item is not Book)
  252. {
  253. var pctIn = decimal.Divide(positionTicks, runtimeTicks) * 100;
  254. if (pctIn < _config.Configuration.MinResumePct)
  255. {
  256. // ignore progress during the beginning
  257. positionTicks = 0;
  258. }
  259. else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= runtimeTicks)
  260. {
  261. // mark as completed close to the end
  262. positionTicks = 0;
  263. data.Played = playedToCompletion = true;
  264. }
  265. else
  266. {
  267. // Enforce MinResumeDuration
  268. var durationSeconds = TimeSpan.FromTicks(runtimeTicks).TotalSeconds;
  269. if (durationSeconds < _config.Configuration.MinResumeDurationSeconds)
  270. {
  271. positionTicks = 0;
  272. data.Played = playedToCompletion = true;
  273. }
  274. }
  275. }
  276. else if (positionTicks > 0 && hasRuntime && item is AudioBook)
  277. {
  278. var playbackPositionInMinutes = TimeSpan.FromTicks(positionTicks).TotalMinutes;
  279. var remainingTimeInMinutes = TimeSpan.FromTicks(runtimeTicks - positionTicks).TotalMinutes;
  280. if (playbackPositionInMinutes < _config.Configuration.MinAudiobookResume)
  281. {
  282. // ignore progress during the beginning
  283. positionTicks = 0;
  284. }
  285. else if (remainingTimeInMinutes < _config.Configuration.MaxAudiobookResume || positionTicks >= runtimeTicks)
  286. {
  287. // mark as completed close to the end
  288. positionTicks = 0;
  289. data.Played = playedToCompletion = true;
  290. }
  291. }
  292. else if (!hasRuntime)
  293. {
  294. // If we don't know the runtime we'll just have to assume it was fully played
  295. data.Played = playedToCompletion = true;
  296. positionTicks = 0;
  297. }
  298. if (!item.SupportsPlayedStatus)
  299. {
  300. positionTicks = 0;
  301. data.Played = false;
  302. }
  303. if (!item.SupportsPositionTicksResume)
  304. {
  305. positionTicks = 0;
  306. }
  307. data.PlaybackPositionTicks = positionTicks;
  308. return playedToCompletion;
  309. }
  310. }
  311. }