From 1c1447362efb39b086a4fcaa1f3ece694f4d749a Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 17 Jan 2026 13:07:01 +0100 Subject: Skip metadata refresh for already-indexed items during validation Optimizes the validation process by checking if items already exist in the database before attempting to refresh metadata. --- .../Library/Validators/ArtistsValidator.cs | 15 ++++++++++++--- .../Library/Validators/GenresValidator.cs | 15 +++++++++++++-- .../Library/Validators/MusicGenresValidator.cs | 17 +++++++++++++++-- .../Library/Validators/StudiosValidator.cs | 15 +++++++++++++-- 4 files changed, 53 insertions(+), 9 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index 7cc851b73b..8de3bff7ab 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -50,21 +50,28 @@ public class ArtistsValidator public async Task Run(IProgress progress, CancellationToken cancellationToken) { var names = _itemRepo.GetAllArtistNames(); + var existingArtistIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicArtist] + }).ToHashSet(); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { var item = _libraryManager.GetArtist(name); - - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingArtistIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { - // Don't clutter the log throw; } catch (Exception ex) @@ -80,6 +87,8 @@ public class ArtistsValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicArtist], diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs index fbfc9f7d54..09ede8bb8d 100644 --- a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -48,17 +49,25 @@ public class GenresValidator public async Task Run(IProgress progress, CancellationToken cancellationToken) { var names = _itemRepo.GetGenreNames(); + var existingGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Genre] + }).ToHashSet(); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { var item = _libraryManager.GetGenre(name); - - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingGenreIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -78,6 +87,8 @@ public class GenresValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new genres out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre], diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs index 6203bce2bc..4365707529 100644 --- a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs @@ -1,6 +1,9 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Extensions.Logging; @@ -45,17 +48,25 @@ public class MusicGenresValidator public async Task Run(IProgress progress, CancellationToken cancellationToken) { var names = _itemRepo.GetMusicGenreNames(); + var existingMusicGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicGenre] + }).ToHashSet(); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { var item = _libraryManager.GetMusicGenre(name); - - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingMusicGenreIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -75,6 +86,8 @@ public class MusicGenresValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new music genres out of {TotalCount} total", refreshed, count); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index 5b87e4d9d0..dd124feece 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -49,17 +50,25 @@ public class StudiosValidator public async Task Run(IProgress progress, CancellationToken cancellationToken) { var names = _itemRepo.GetStudioNames(); + var existingStudioIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Studio] + }).ToHashSet(); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { var item = _libraryManager.GetStudio(name); - - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingStudioIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -79,6 +88,8 @@ public class StudiosValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new studios out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Studio], -- cgit v1.2.3 From d3d4d37e82b9b394804f84dbdb0b873fd10f3c29 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 17 Jan 2026 13:10:00 +0100 Subject: Simplify UserDataManager and remove unused private methods Removes unused private GetUserData and GetUserDataInternal methods. Moves GetUserDataBatch to be an abstract interface method rather than having a default implementation for clarity. --- .../Library/UserDataManager.cs | 85 +++++++++++++--------- .../Library/IUserDataManager.cs | 8 ++ 2 files changed, 60 insertions(+), 33 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 72c8d7a9d2..9e138dbdaa 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -177,53 +177,72 @@ namespace Emby.Server.Implementations.Library }; } - private UserItemData? GetUserData(User user, Guid itemId, List keys) + /// + public Dictionary GetUserDataBatch(IReadOnlyList items, User user) { - var cacheKey = GetCacheKey(user.InternalId, itemId); + var result = new Dictionary(items.Count); + var itemsNeedingQuery = new List<(BaseItem Item, List Keys)>(); - if (_cache.TryGet(cacheKey, out var data)) + foreach (var item in items) { - return data; - } - - data = GetUserDataInternal(user.Id, itemId, keys); - - if (data is null) - { - return new UserItemData() + var cacheKey = GetCacheKey(user.InternalId, item.Id); + if (_cache.TryGet(cacheKey, out var cachedData)) { - Key = keys[0], - }; + 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)); + } + } } - return _cache.GetOrAdd(cacheKey, _ => data); - } - - private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List keys) - { - if (keys.Count == 0) + if (itemsNeedingQuery.Count == 0) { - return null; + return result; } - using var context = _repository.CreateDbContext(); - var userData = context.UserData.AsNoTracking().Where(e => e.ItemId == itemId && keys.Contains(e.CustomDataKey) && e.UserId.Equals(userId)).ToArray(); - - if (userData.Length > 0) + // 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) { - var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N")); - if (directDataReference is not null) + using var context = _repository.CreateDbContext(); + var userDataArray = context.UserData + .AsNoTracking() + .Where(e => allItemIds.Contains(e.ItemId) && allKeys.Contains(e.CustomDataKey) && e.UserId.Equals(user.Id)) + .ToArray(); + + var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); + foreach (var (item, keys) in itemsNeedingQuery) { - return Map(directDataReference); - } + 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 }; + } - return Map(userData.First()); + result[item.Id] = userData; + var cacheKey = GetCacheKey(user.InternalId, item.Id); + _cache.AddOrUpdate(cacheKey, userData); + } } - return new UserItemData - { - Key = keys.Last()! - }; + return result; } /// diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index eb46611dd9..798812bf1f 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -54,6 +54,14 @@ namespace MediaBrowser.Controller.Library /// User data dto. UserItemDataDto? GetUserDataDto(BaseItem item, User user); + /// + /// Gets user data for multiple items in a single batch operation. + /// + /// The items to get user data for. + /// The user. + /// A dictionary mapping item IDs to their user data. + Dictionary GetUserDataBatch(IReadOnlyList items, User user); + /// /// Gets the user data dto. /// -- cgit v1.2.3 From 0b77f970489869395b9a032c84c571c286d95a7f Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 17 Jan 2026 13:35:49 +0100 Subject: Optimize latest items grouping in UserViewManager - Use dictionary lookup for O(1) container grouping instead of O(n) FirstOrDefault searches - Add optimized path for movies in GetLatestItemList - Reduce query limit multiplier from 5x to 2x - Update to collection expression syntax --- .../Library/UserViewManager.cs | 48 +++++++++++----------- 1 file changed, 25 insertions(+), 23 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 6fb53ff15d..c5a1e5d940 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -138,7 +138,7 @@ namespace Emby.Server.Implementations.Library list = list.Where(i => !user.GetPreferenceValues(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList(); } - var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList(); + var sorted = _libraryManager.Sort(list, user, [ItemSortBy.SortName], SortOrder.Ascending).ToList(); var orders = user.GetPreferenceValues(PreferenceKind.OrderedViews); return list @@ -205,7 +205,7 @@ namespace Emby.Server.Implementations.Library var libraryItems = GetItemsForLatestItems(request.User, request, options); var list = new List>>(); - + var containerIndexMap = new Dictionary(); foreach (var item in libraryItems) { // Only grab the index container for media @@ -213,20 +213,16 @@ namespace Emby.Server.Implementations.Library if (container is null) { - list.Add(new Tuple>(null, new List { item })); + list.Add(new Tuple>(null!, new List { item })); + } + else if (containerIndexMap.TryGetValue(container.Id, out var existingIndex)) + { + list[existingIndex].Item2.Add(item); } else { - var current = list.FirstOrDefault(i => i.Item1 is not null && i.Item1.Id.Equals(container.Id)); - - if (current is not null) - { - current.Item2.Add(item); - } - else - { - list.Add(new Tuple>(container, new List { item })); - } + containerIndexMap[container.Id] = list.Count; + list.Add(new Tuple>(container, new List { item })); } if (list.Count >= request.Limit) @@ -255,7 +251,7 @@ namespace Emby.Server.Implementations.Library return _channelManager.GetLatestChannelItemsInternal( new InternalItemsQuery(user) { - ChannelIds = new[] { parentId }, + ChannelIds = [parentId], IsPlayed = request.IsPlayed, StartIndex = request.StartIndex, Limit = request.Limit, @@ -301,11 +297,11 @@ namespace Emby.Server.Implementations.Library { if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies)) { - includeItemTypes = new[] { BaseItemKind.Movie }; + includeItemTypes = [BaseItemKind.Movie]; } else if (hasCollectionType.All(i => i.CollectionType == CollectionType.tvshows)) { - includeItemTypes = new[] { BaseItemKind.Episode }; + includeItemTypes = [BaseItemKind.Episode]; } } } @@ -344,29 +340,29 @@ namespace Emby.Server.Implementations.Library } var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0 - ? new[] - { + ? + [ BaseItemKind.Person, BaseItemKind.Studio, BaseItemKind.Year, BaseItemKind.MusicGenre, BaseItemKind.Genre - } + ] : Array.Empty(); var query = new InternalItemsQuery(user) { IncludeItemTypes = includeItemTypes, - OrderBy = new[] - { + OrderBy = + [ (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending), (ItemSortBy.ProductionYear, SortOrder.Descending) - }, + ], IsFolder = includeItemTypes.Length == 0 ? false : null, ExcludeItemTypes = excludeItemTypes, IsVirtualItem = false, - Limit = limit * 5, + Limit = limit * 2, IsPlayed = isPlayed, DtoOptions = options, MediaTypes = mediaTypes @@ -394,6 +390,12 @@ namespace Emby.Server.Implementations.Library query.Limit = limit; return _libraryManager.GetLatestItemList(query, parents, CollectionType.music); } + + if (collectionType == CollectionType.movies) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies); + } } return _libraryManager.GetItemList(query, parents); -- cgit v1.2.3 From 5996c4afce11249804d24f1caa3a99b390543c4d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 17 Jan 2026 17:10:07 +0100 Subject: Complete LinkedChildren integration and batch DTO optimizations This commit integrates remaining performance changes: - Add batch user data fetching in DtoService to reduce N+1 queries - Add GetNextUpEpisodesBatch in TVSeriesManager for efficient batch retrieval - Update Video/Movie/BoxSet to use LibraryManager for alternate versions - Transition LinkedChild to use ItemId instead of Path (obsolete Path/LibraryItemId) - Update providers and controllers for LinkedChildren-based references - Add NextUpEpisodeBatchResult for batched episode queries - Integrate IDescendantQueryProvider in SqliteDatabaseProvider --- .../Collections/CollectionManager.cs | 4 +- Emby.Server.Implementations/Dto/DtoService.cs | 96 +- .../Library/LibraryManager.cs | 119 +- .../Tasks/CleanupCollectionAndPlaylistPathsTask.cs | 20 +- .../Session/SessionManager.cs | 1 - Emby.Server.Implementations/TV/TVSeriesManager.cs | 205 ++- Jellyfin.Api/Controllers/LibraryController.cs | 37 +- Jellyfin.Api/Controllers/VideosController.cs | 10 +- .../Item/BaseItemRepository.cs | 1608 ++++++++++++++++---- MediaBrowser.Controller/Dto/IDtoService.cs | 3 +- MediaBrowser.Controller/Entities/BaseItem.cs | 19 +- .../Entities/CollectionFolder.cs | 7 + MediaBrowser.Controller/Entities/Folder.cs | 201 +-- .../Entities/InternalItemsQuery.cs | 4 + MediaBrowser.Controller/Entities/LinkedChild.cs | 20 +- .../Entities/LinkedChildComparer.cs | 23 +- .../Entities/LinkedChildType.cs | 12 +- MediaBrowser.Controller/Entities/Movies/BoxSet.cs | 4 +- MediaBrowser.Controller/Entities/Movies/Movie.cs | 36 +- MediaBrowser.Controller/Entities/TV/Episode.cs | 4 +- MediaBrowser.Controller/Entities/TV/Series.cs | 4 +- .../Entities/UserViewBuilder.cs | 243 +-- MediaBrowser.Controller/Entities/Video.cs | 69 +- MediaBrowser.Controller/Library/ILibraryManager.cs | 47 + .../Persistence/IItemRepository.cs | 72 + .../Persistence/NextUpEpisodeBatchResult.cs | 38 + .../Parsers/BaseItemXmlParser.cs | 5 +- MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs | 31 +- .../BoxSets/BoxSetMetadataService.cs | 2 +- MediaBrowser.Providers/Manager/ProviderManager.cs | 144 +- .../Playlists/PlaylistItemsProvider.cs | 12 +- .../Playlists/PlaylistMetadataService.cs | 2 +- MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs | 32 +- .../IJellyfinDatabaseProvider.cs | 6 + .../SqliteDatabaseProvider.cs | 3 + 35 files changed, 2242 insertions(+), 901 deletions(-) create mode 100644 MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index a320a774c6..0ede5665f9 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -272,7 +272,7 @@ namespace Emby.Server.Implementations.Collections { var childItem = _libraryManager.GetItemById(guidId); - var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)) || (childItem is not null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase))); + var child = collection.LinkedChildren.FirstOrDefault(i => i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)); if (child is null) { @@ -342,7 +342,7 @@ namespace Emby.Server.Implementations.Collections // this is kind of a performance hack because only Video has alternate versions that should be in a box set? if (item is Video video) { - foreach (var childId in video.GetLocalAlternateVersionIds()) + foreach (var childId in _libraryManager.GetLocalAlternateVersionIds(video)) { if (!results.ContainsKey(childId)) { diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index c5dc3b054c..236b3fabe4 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -153,17 +153,42 @@ namespace Emby.Server.Implementations.Dto private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; /// - public IReadOnlyList GetBaseItemDtos(IReadOnlyList items, DtoOptions options, User? user = null, BaseItem? owner = null) + public IReadOnlyList GetBaseItemDtos(IReadOnlyList items, DtoOptions options, User? user = null, BaseItem? owner = null, bool skipVisibilityCheck = false) { - var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); + var accessibleItems = skipVisibilityCheck || user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); var returnItems = new BaseItemDto[accessibleItems.Count]; List<(BaseItem, BaseItemDto)>? programTuples = null; List<(BaseItemDto, LiveTvChannel)>? channelTuples = null; + // Batch-fetch user data for all items + Dictionary? userDataBatch = null; + if (user is not null && options.EnableUserData) + { + userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user); + } + + // Pre-compute collection folders once to avoid N+1 queries in CanDelete + List? allCollectionFolders = null; + if (user is not null && options.ContainsField(ItemFields.CanDelete)) + { + allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType().ToList(); + } + + // Batch-fetch child counts for all folders to avoid N+1 queries + Dictionary? childCountBatch = null; + if (options.ContainsField(ItemFields.ChildCount)) + { + var folderIds = accessibleItems.OfType().Select(f => f.Id).ToList(); + if (folderIds.Count > 0) + { + childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id); + } + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; - var dto = GetBaseItemDtoInternal(item, options, user, owner); + var dto = GetBaseItemDtoInternal(item, options, user, owner, userDataBatch?.GetValueOrDefault(item.Id), allCollectionFolders, childCountBatch); if (item is LiveTvChannel tvChannel) { @@ -197,7 +222,7 @@ namespace Emby.Server.Implementations.Dto public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null) { - var dto = GetBaseItemDtoInternal(item, options, user, owner); + var dto = GetBaseItemDtoInternal(item, options, user, owner, null); if (item is LiveTvChannel tvChannel) { LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user); @@ -215,7 +240,7 @@ namespace Emby.Server.Implementations.Dto return dto; } - private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null) + private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null, UserItemData? userData = null, List? allCollectionFolders = null, Dictionary? childCountBatch = null) { var dto = new BaseItemDto { @@ -252,7 +277,7 @@ namespace Emby.Server.Implementations.Dto if (user is not null) { - AttachUserSpecificInfo(dto, item, user, options); + AttachUserSpecificInfo(dto, item, user, options, userData, childCountBatch); } if (item is IHasMediaSources @@ -274,7 +299,9 @@ namespace Emby.Server.Implementations.Dto { dto.CanDelete = user is null ? item.CanDelete() - : item.CanDelete(user); + : allCollectionFolders is not null + ? item.CanDelete(user, allCollectionFolders) + : item.CanDelete(user); } if (options.ContainsField(ItemFields.CanDownload)) @@ -458,7 +485,7 @@ namespace Emby.Server.Implementations.Dto /// /// Attaches the user specific info. /// - private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options) + private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options, UserItemData? userData = null, Dictionary? childCountBatch = null) { if (item.IsFolder) { @@ -466,7 +493,17 @@ namespace Emby.Server.Implementations.Dto if (options.EnableUserData) { - dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options); + if (userData is not null) + { + // Use pre-fetched user data + dto.UserData = GetUserItemDataDto(userData, item.Id); + item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + } + else + { + // Fall back to individual fetch + dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options); + } } if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library) @@ -485,7 +522,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ChildCount)) { - dto.ChildCount ??= GetChildCount(folder, user); + dto.ChildCount ??= GetChildCount(folder, user, childCountBatch); } } @@ -503,7 +540,17 @@ namespace Emby.Server.Implementations.Dto { if (options.EnableUserData) { - dto.UserData = _userDataRepository.GetUserDataDto(item, user); + if (userData is not null) + { + // Use pre-fetched user data + dto.UserData = GetUserItemDataDto(userData, item.Id); + item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + } + else + { + // Fall back to individual fetch + dto.UserData = _userDataRepository.GetUserDataDto(item, user); + } } } @@ -513,7 +560,25 @@ namespace Emby.Server.Implementations.Dto } } - private static int GetChildCount(Folder folder, User user) + private static UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId) + { + ArgumentNullException.ThrowIfNull(data); + + return new UserItemDataDto + { + IsFavorite = data.IsFavorite, + Likes = data.Likes, + PlaybackPositionTicks = data.PlaybackPositionTicks, + PlayCount = data.PlayCount, + Rating = data.Rating, + Played = data.Played, + LastPlayedDate = data.LastPlayedDate, + ItemId = itemId, + Key = data.Key + }; + } + + private static int GetChildCount(Folder folder, User user, Dictionary? childCountBatch) { // Right now this is too slow to calculate for top level folders on a per-user basis // Just return something so that apps that are expecting a value won't think the folders are empty @@ -522,6 +587,13 @@ namespace Emby.Server.Implementations.Dto return Random.Shared.Next(1, 10); } + // Use pre-fetched batch data if available + if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count)) + { + return count; + } + + // Fall back to individual query for special cases (Series, Season, etc.) return folder.GetChildCount(user); } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f7f5c387e1..2acfd68c36 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -406,6 +406,37 @@ namespace Emby.Server.Implementations.Library item.Id); } + // If deleting a primary version video, clear PrimaryVersionId from alternate versions + if (item is Video video && string.IsNullOrEmpty(video.PrimaryVersionId)) + { + var alternateVersions = GetLocalAlternateVersionIds(video) + .Concat(GetLinkedAlternateVersions(video).Select(v => v.Id)) + .Distinct() + .Select(id => GetItemById(id)) + .OfType