< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Library.UserDataManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Library/UserDataManager.cs
Line coverage
32%
Covered lines: 57
Uncovered lines: 116
Coverable lines: 173
Total lines: 389
Line coverage: 32.9%
Branch coverage
25%
Covered branches: 21
Total branches: 82
Branch coverage: 25.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 AM Line coverage: 15.9% (26/163) Branch coverage: 5.8% (4/68) Total lines: 3685/4/2026 - 12:15:16 AM Line coverage: 32.9% (57/173) Branch coverage: 25.6% (21/82) Total lines: 389 1/23/2026 - 12:11:06 AM Line coverage: 15.9% (26/163) Branch coverage: 5.8% (4/68) Total lines: 3685/4/2026 - 12:15:16 AM Line coverage: 32.9% (57/173) Branch coverage: 25.6% (21/82) Total lines: 389

Coverage delta

Coverage delta 20 -20

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SaveUserData(...)0%4260%
SaveUserData(...)0%272160%
Map(...)100%210%
Map(...)100%210%
GetUserDataBatch(...)70.83%252488.23%
GetCacheKey(...)100%11100%
GetUserData(...)75%44100%
GetUserDataDto(...)100%210%
GetUserDataDto(...)50%2283.33%
GetUserItemDataDto(...)100%11100%
UpdatePlayState(...)0%930300%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/Library/UserDataManager.cs

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Globalization;
 6using System.Linq;
 7using System.Threading;
 8using BitFaster.Caching.Lru;
 9using Jellyfin.Database.Implementations;
 10using Jellyfin.Database.Implementations.Entities;
 11using MediaBrowser.Controller.Configuration;
 12using MediaBrowser.Controller.Dto;
 13using MediaBrowser.Controller.Entities;
 14using MediaBrowser.Controller.Library;
 15using MediaBrowser.Model.Dto;
 16using MediaBrowser.Model.Entities;
 17using Microsoft.EntityFrameworkCore;
 18using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
 19using Book = MediaBrowser.Controller.Entities.Book;
 20
 21namespace Emby.Server.Implementations.Library
 22{
 23    /// <summary>
 24    /// Class UserDataManager.
 25    /// </summary>
 26    public class UserDataManager : IUserDataManager
 27    {
 28        private readonly IServerConfigurationManager _config;
 29        private readonly IDbContextFactory<JellyfinDbContext> _repository;
 30        private readonly FastConcurrentLru<string, UserItemData> _cache;
 31
 32        /// <summary>
 33        /// Initializes a new instance of the <see cref="UserDataManager"/> class.
 34        /// </summary>
 35        /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
 36        /// <param name="repository">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</para
 37        public UserDataManager(
 38            IServerConfigurationManager config,
 39            IDbContextFactory<JellyfinDbContext> repository)
 40        {
 2141            _config = config;
 2142            _repository = repository;
 2143            _cache = new FastConcurrentLru<string, UserItemData>(Environment.ProcessorCount, _config.Configuration.Cache
 2144        }
 45
 46        /// <inheritdoc />
 47        public event EventHandler<UserDataSaveEventArgs>? UserDataSaved;
 48
 49        /// <inheritdoc />
 50        public void SaveUserData(User user, BaseItem item, UserItemData userData, UserDataSaveReason reason, Cancellatio
 51        {
 052            ArgumentNullException.ThrowIfNull(userData);
 53
 054            ArgumentNullException.ThrowIfNull(item);
 55
 056            cancellationToken.ThrowIfCancellationRequested();
 57
 058            var keys = item.GetUserDataKeys();
 59
 060            using var dbContext = _repository.CreateDbContext();
 061            using var transaction = dbContext.Database.BeginTransaction();
 62
 063            foreach (var key in keys)
 64            {
 065                userData.Key = key;
 066                var userDataEntry = Map(userData, user.Id, item.Id);
 067                if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && 
 68                {
 069                    dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified;
 70                }
 71                else
 72                {
 073                    dbContext.UserData.Add(userDataEntry);
 74                }
 75            }
 76
 077            dbContext.SaveChanges();
 078            transaction.Commit();
 79
 080            var userId = user.InternalId;
 081            var cacheKey = GetCacheKey(userId, item.Id);
 082            _cache.AddOrUpdate(cacheKey, userData);
 083            item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate th
 84
 085            UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
 086            {
 087                Keys = keys,
 088                UserData = userData,
 089                SaveReason = reason,
 090                UserId = user.Id,
 091                Item = item
 092            });
 093        }
 94
 95        /// <inheritdoc />
 96        public void SaveUserData(User user, BaseItem item, UpdateUserItemDataDto userDataDto, UserDataSaveReason reason)
 97        {
 098            ArgumentNullException.ThrowIfNull(user);
 099            ArgumentNullException.ThrowIfNull(item);
 0100            ArgumentNullException.ThrowIfNull(userDataDto);
 101
 0102            var userData = GetUserData(user, item) ?? throw new InvalidOperationException("UserData should not be null."
 103
 0104            if (userDataDto.PlaybackPositionTicks.HasValue)
 105            {
 0106                userData.PlaybackPositionTicks = userDataDto.PlaybackPositionTicks.Value;
 107            }
 108
 0109            if (userDataDto.PlayCount.HasValue)
 110            {
 0111                userData.PlayCount = userDataDto.PlayCount.Value;
 112            }
 113
 0114            if (userDataDto.IsFavorite.HasValue)
 115            {
 0116                userData.IsFavorite = userDataDto.IsFavorite.Value;
 117            }
 118
 0119            if (userDataDto.Likes.HasValue)
 120            {
 0121                userData.Likes = userDataDto.Likes.Value;
 122            }
 123
 0124            if (userDataDto.Played.HasValue)
 125            {
 0126                userData.Played = userDataDto.Played.Value;
 127            }
 128
 0129            if (userDataDto.LastPlayedDate.HasValue)
 130            {
 0131                userData.LastPlayedDate = userDataDto.LastPlayedDate.Value;
 132            }
 133
 0134            if (userDataDto.Rating.HasValue)
 135            {
 0136                userData.Rating = userDataDto.Rating.Value;
 137            }
 138
 0139            SaveUserData(user, item, userData, reason, CancellationToken.None);
 0140        }
 141
 142        private UserData Map(UserItemData dto, Guid userId, Guid itemId)
 143        {
 0144            return new UserData()
 0145            {
 0146                ItemId = itemId,
 0147                CustomDataKey = dto.Key,
 0148                Item = null,
 0149                User = null,
 0150                AudioStreamIndex = dto.AudioStreamIndex,
 0151                IsFavorite = dto.IsFavorite,
 0152                LastPlayedDate = dto.LastPlayedDate,
 0153                Likes = dto.Likes,
 0154                PlaybackPositionTicks = dto.PlaybackPositionTicks,
 0155                PlayCount = dto.PlayCount,
 0156                Played = dto.Played,
 0157                Rating = dto.Rating,
 0158                UserId = userId,
 0159                SubtitleStreamIndex = dto.SubtitleStreamIndex,
 0160            };
 161        }
 162
 163        private static UserItemData Map(UserData dto)
 164        {
 0165            return new UserItemData()
 0166            {
 0167                Key = dto.CustomDataKey!,
 0168                AudioStreamIndex = dto.AudioStreamIndex,
 0169                IsFavorite = dto.IsFavorite,
 0170                LastPlayedDate = dto.LastPlayedDate,
 0171                Likes = dto.Likes,
 0172                PlaybackPositionTicks = dto.PlaybackPositionTicks,
 0173                PlayCount = dto.PlayCount,
 0174                Played = dto.Played,
 0175                Rating = dto.Rating,
 0176                SubtitleStreamIndex = dto.SubtitleStreamIndex,
 0177            };
 178        }
 179
 180        /// <inheritdoc />
 181        public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
 182        {
 4183            var result = new Dictionary<Guid, UserItemData>(items.Count);
 4184            var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>();
 185
 14186            foreach (var item in items)
 187            {
 3188                var cacheKey = GetCacheKey(user.InternalId, item.Id);
 3189                if (_cache.TryGet(cacheKey, out var cachedData))
 190                {
 2191                    result[item.Id] = cachedData;
 192                }
 193                else
 194                {
 1195                    var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
 1196                    if (userData is not null)
 197                    {
 0198                        result[item.Id] = userData;
 0199                        _cache.AddOrUpdate(cacheKey, userData);
 200                    }
 201                    else
 202                    {
 1203                        var keys = item.GetUserDataKeys();
 1204                        itemsNeedingQuery.Add((item, keys));
 205                    }
 206                }
 207            }
 208
 4209            if (itemsNeedingQuery.Count == 0)
 210            {
 3211                return result;
 212            }
 213
 214            // Build a single query for all missing items
 1215            var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
 1216            var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
 1217            if (allKeys.Count > 0)
 218            {
 1219                using var context = _repository.CreateDbContext();
 1220                var userDataArray = context.UserData
 1221                    .AsNoTracking()
 1222                    .Where(e => e.UserId.Equals(user.Id))
 1223                    .WhereOneOrMany(allItemIds, e => e.ItemId)
 1224                    .WhereOneOrMany(allKeys, e => e.CustomDataKey)
 1225                    .ToArray();
 226
 1227                var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
 4228                foreach (var (item, keys) in itemsNeedingQuery)
 229                {
 230                    UserItemData userData;
 1231                    if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
 232                    {
 0233                        var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("
 0234                        userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First()
 235                    }
 236                    else
 237                    {
 1238                        userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
 239                    }
 240
 1241                    result[item.Id] = userData;
 1242                    var cacheKey = GetCacheKey(user.InternalId, item.Id);
 1243                    _cache.AddOrUpdate(cacheKey, userData);
 244                }
 245            }
 246
 1247            return result;
 248        }
 249
 250        /// <summary>
 251        /// Gets the internal key.
 252        /// </summary>
 253        /// <returns>System.String.</returns>
 254        private static string GetCacheKey(long internalUserId, Guid itemId)
 255        {
 4256            return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N", CultureInfo.Invari
 257        }
 258
 259        /// <inheritdoc />
 260        public UserItemData? GetUserData(User user, BaseItem item)
 261        {
 6262            return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData(
 6263            {
 6264                Key = item.GetUserDataKeys()[0],
 6265            };
 266        }
 267
 268        /// <inheritdoc />
 269        public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
 0270            => GetUserDataDto(item, null, user, new DtoOptions());
 271
 272        /// <inheritdoc />
 273        public UserItemDataDto? GetUserDataDto(BaseItem item, BaseItemDto? itemDto, User user, DtoOptions options)
 274        {
 6275            var userData = GetUserData(user, item);
 6276            if (userData is null)
 277            {
 0278                return null;
 279            }
 280
 6281            var dto = GetUserItemDataDto(userData, item.Id);
 282
 6283            item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
 6284            return dto;
 285        }
 286
 287        /// <summary>
 288        /// Converts a UserItemData to a DTOUserItemData.
 289        /// </summary>
 290        /// <param name="data">The data.</param>
 291        /// <param name="itemId">The reference key to an Item.</param>
 292        /// <returns>DtoUserItemData.</returns>
 293        /// <exception cref="ArgumentNullException"><paramref name="data"/> is <c>null</c>.</exception>
 294        private UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId)
 295        {
 6296            ArgumentNullException.ThrowIfNull(data);
 297
 6298            return new UserItemDataDto
 6299            {
 6300                IsFavorite = data.IsFavorite,
 6301                Likes = data.Likes,
 6302                PlaybackPositionTicks = data.PlaybackPositionTicks,
 6303                PlayCount = data.PlayCount,
 6304                Rating = data.Rating,
 6305                Played = data.Played,
 6306                LastPlayedDate = data.LastPlayedDate,
 6307                ItemId = itemId,
 6308                Key = data.Key
 6309            };
 310        }
 311
 312        /// <inheritdoc />
 313        public bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks)
 314        {
 0315            var playedToCompletion = false;
 316
 0317            var runtimeTicks = item.GetRunTimeTicksForPlayState();
 318
 0319            var positionTicks = reportedPositionTicks ?? runtimeTicks;
 0320            var hasRuntime = runtimeTicks > 0;
 321
 322            // If a position has been reported, and if we know the duration
 0323            if (positionTicks > 0 && hasRuntime && item is not AudioBook && item is not Book)
 324            {
 0325                var pctIn = decimal.Divide(positionTicks, runtimeTicks) * 100;
 326
 0327                if (pctIn < _config.Configuration.MinResumePct)
 328                {
 329                    // ignore progress during the beginning
 0330                    positionTicks = 0;
 331                }
 0332                else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= (runtimeTicks - TimeSpan.TicksPe
 333                {
 334                    // mark as completed close to the end
 0335                    positionTicks = 0;
 0336                    data.Played = playedToCompletion = true;
 337                }
 338                else
 339                {
 340                    // Enforce MinResumeDuration
 0341                    var durationSeconds = TimeSpan.FromTicks(runtimeTicks).TotalSeconds;
 0342                    if (durationSeconds < _config.Configuration.MinResumeDurationSeconds)
 343                    {
 0344                        positionTicks = 0;
 0345                        data.Played = playedToCompletion = true;
 346                    }
 347                }
 348            }
 0349            else if (positionTicks > 0 && hasRuntime && item is AudioBook)
 350            {
 0351                var playbackPositionInMinutes = TimeSpan.FromTicks(positionTicks).TotalMinutes;
 0352                var remainingTimeInMinutes = TimeSpan.FromTicks(runtimeTicks - positionTicks).TotalMinutes;
 353
 0354                if (playbackPositionInMinutes < _config.Configuration.MinAudiobookResume)
 355                {
 356                    // ignore progress during the beginning
 0357                    positionTicks = 0;
 358                }
 0359                else if (remainingTimeInMinutes < _config.Configuration.MaxAudiobookResume || positionTicks >= runtimeTi
 360                {
 361                    // mark as completed close to the end
 0362                    positionTicks = 0;
 0363                    data.Played = playedToCompletion = true;
 364                }
 365            }
 0366            else if (!hasRuntime)
 367            {
 368                // If we don't know the runtime we'll just have to assume it was fully played
 0369                data.Played = playedToCompletion = true;
 0370                positionTicks = 0;
 371            }
 372
 0373            if (!item.SupportsPlayedStatus)
 374            {
 0375                positionTicks = 0;
 0376                data.Played = false;
 377            }
 378
 0379            if (!item.SupportsPositionTicksResume)
 380            {
 0381                positionTicks = 0;
 382            }
 383
 0384            data.PlaybackPositionTicks = positionTicks;
 385
 0386            return playedToCompletion;
 387        }
 388    }
 389}

Methods/Properties

.ctor(MediaBrowser.Controller.Configuration.IServerConfigurationManager,Microsoft.EntityFrameworkCore.IDbContextFactory`1<Jellyfin.Database.Implementations.JellyfinDbContext>)
SaveUserData(Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Controller.Entities.UserItemData,MediaBrowser.Model.Entities.UserDataSaveReason,System.Threading.CancellationToken)
SaveUserData(Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Model.Dto.UpdateUserItemDataDto,MediaBrowser.Model.Entities.UserDataSaveReason)
Map(MediaBrowser.Controller.Entities.UserItemData,System.Guid,System.Guid)
Map(Jellyfin.Database.Implementations.Entities.UserData)
GetUserDataBatch(System.Collections.Generic.IReadOnlyList`1<MediaBrowser.Controller.Entities.BaseItem>,Jellyfin.Database.Implementations.Entities.User)
GetCacheKey(System.Int64,System.Guid)
GetUserData(Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Entities.BaseItem)
GetUserDataDto(MediaBrowser.Controller.Entities.BaseItem,Jellyfin.Database.Implementations.Entities.User)
GetUserDataDto(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Model.Dto.BaseItemDto,Jellyfin.Database.Implementations.Entities.User,MediaBrowser.Controller.Dto.DtoOptions)
GetUserItemDataDto(MediaBrowser.Controller.Entities.UserItemData,System.Guid)
UpdatePlayState(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Controller.Entities.UserItemData,System.Nullable`1<System.Int64>)