From 8150ee2484dabed61718ef43d5c98cdd2c0fa01c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:13:48 -0400 Subject: Backport pull request #17842 from jellyfin/release-12.z Fix versions of a video still listing separately from their group and preserve manual merges Original-merge: cabec7ec28df671e3bb1c360c32db60b085f4084 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/Search/SearchManager.cs | 14 +++++++++++--- .../Library/Search/SqlSearchProvider.cs | 7 +++++++ .../Library/SimilarItems/MovieSimilarItemsProvider.cs | 16 +++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index 306a8673d5..a8ee416b31 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -143,11 +143,19 @@ public class SearchManager : ISearchManager baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); - var allowedIds = await baseQuery - .Select(e => e.Id) - .ToHashSetAsync(cancellationToken) + var allowed = await baseQuery + .Select(e => new { e.Id, e.PrimaryVersionId }) + .ToListAsync(cancellationToken) .ConfigureAwait(false); + var allowedIds = allowed.Select(e => e.Id).ToHashSet(); + + // A provider can return both an alternate version and the primary it belongs to, and the + // two are one item to the user. + allowedIds.ExceptWith(allowed + .Where(e => e.PrimaryVersionId.HasValue && allowedIds.Contains(e.PrimaryVersionId.Value)) + .Select(e => e.Id)); + if (allowedIds.Count == candidates.Count) { return candidates; diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index c4d3b249d5..2cbfb6a4fa 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -115,6 +115,7 @@ public class SqlSearchProvider : IInternalSearchProvider dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); dbQuery = ApplyParentFilter(dbQuery, query.ParentId); dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query); + dbQuery = ExcludeVersionsOfMatchedPrimaries(dbQuery); // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it @@ -193,6 +194,12 @@ public class SqlSearchProvider : IInternalSearchProvider return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); } + private static IQueryable ExcludeVersionsOfMatchedPrimaries(IQueryable query) + { + var matched = query; + return query.Where(e => e.PrimaryVersionId == null || !matched.Any(p => p.Id == e.PrimaryVersionId)); + } + private IQueryable ApplyUserAccessFilter( JellyfinDbContext dbContext, IQueryable query, diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index b1547e72fe..cc8f0fd24e 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,3 +1,5 @@ +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees. + using System; using System.Collections.Generic; using System.Linq; @@ -172,7 +174,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider e.Id) - .Select(e => new { e.Id, e.PresentationUniqueKey }) + .Select(e => new { e.Id, e.PresentationUniqueKey, e.PrimaryVersionId }) .ToListAsync(cancellationToken).ConfigureAwait(false); // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey @@ -189,6 +191,9 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider scores.ContainsKey(x.Id)) .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + // Two versions of one movie score the same, so name the primary as the + // representative of the group rather than whichever came back first. + .ThenBy(x => x.PrimaryVersionId.HasValue) .DistinctBy(x => x.PresentationUniqueKey) .Take(limit) .Select(x => x.Id) @@ -245,6 +250,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider e.PrimaryVersionId != null + && context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId)) + .Select(e => e.Id); + foreach (var (valueType, weight) in _itemValueDimensions) { var sourceRows = await context.ItemValuesMap.AsNoTracking() @@ -260,7 +270,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider !m.Item.PrimaryVersionId.HasValue && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Where(m => !hiddenVersionIds.Contains(m.ItemId) && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) .ToListAsync(cancellationToken).ConfigureAwait(false); @@ -276,7 +286,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider 0) { var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => !m.Item.PrimaryVersionId.HasValue) + .Where(m => !hiddenVersionIds.Contains(m.ItemId)) .Where(m => context.PeopleBaseItemMap .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) .Select(s => s.PeopleId) -- cgit v1.2.3 From 006ecadbe041ea422b34adf6a3fcf6638d189a70 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:15:46 -0400 Subject: Backport pull request #17881 from jellyfin/release-12.z Fix /UserViews exhausting memory and reporting random child counts Original-merge: a838a06aa51eac388a76e9e6421f1a80873c417b Merged-by: crobibero Backported-by: Cody Robibero --- Emby.Server.Implementations/Dto/DtoService.cs | 105 +++++++++-- .../Library/UserViewManager.cs | 11 +- Jellyfin.Api/Controllers/UserViewsController.cs | 2 +- .../Item/BaseItemRepository.QueryBuilding.cs | 34 ++-- .../Item/ItemCountService.cs | 34 +++- .../Item/NextUpService.cs | 17 +- MediaBrowser.Controller/Entities/Movies/BoxSet.cs | 28 ++- .../Entities/UserViewBuilder.cs | 51 +++--- MediaBrowser.Controller/Playlists/Playlist.cs | 20 ++- .../DescendantQueryHelper.cs | 59 +++++++ .../Entities/PlaylistTests.cs | 84 +++++++++ .../Dto/DtoServiceTests.cs | 94 ++++++++++ .../Entities/UserViewBuilderTests.cs | 58 ++++++ .../Item/ItemCountServiceTests.cs | 195 +++++++++++++++++++++ 14 files changed, 709 insertions(+), 83 deletions(-) create mode 100644 tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 59e75691dc..e539508644 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; @@ -192,15 +193,11 @@ namespace Emby.Server.Implementations.Dto itemCountsBatch = GetItemCountsBatch(accessibleItems, user); } - // Batch-fetch child counts for all folders to avoid N+1 queries + // Batch-fetch child counts for all folders to avoid N+1 queries. Dictionary? childCountBatch = null; - if (options.ContainsField(ItemFields.ChildCount)) + if (user is not null && options.ContainsField(ItemFields.ChildCount)) { - var folderIds = accessibleItems.OfType().Select(f => f.Id).ToList(); - if (folderIds.Count > 0) - { - childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user); - } + childCountBatch = GetChildCountBatch(accessibleItems, user); } // Batch-fetch played/total counts for all folders to avoid N+1 queries @@ -714,24 +711,102 @@ namespace Emby.Server.Implementations.Dto }; } - private static int GetChildCount(Folder folder, User user, Dictionary? childCountBatch) + private Dictionary? GetChildCountBatch(IReadOnlyList items, User user) { - // 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 - if (folder is ICollectionFolder || folder is UserView) + Dictionary>? sources = null; + foreach (var folder in items.OfType()) + { + var sourceIds = GetChildCountSourceIds(folder, user); + if (sourceIds.Count > 0) + { + (sources ??= new Dictionary>())[folder.Id] = sourceIds; + } + } + + if (sources is null) + { + return null; + } + + var counts = _libraryManager.GetChildCountBatch( + sources.Values.SelectMany(ids => ids).Distinct().ToList(), + user); + + var result = new Dictionary(sources.Count); + foreach (var (folderId, sourceIds) in sources) { - return Random.Shared.Next(1, 10); + var total = 0; + foreach (var sourceId in sourceIds) + { + total += counts.GetValueOrDefault(sourceId); + } + + result[folderId] = total; } + return result; + } + + private IReadOnlyList GetChildCountSourceIds(Folder folder, User user) + { + if (folder is CollectionFolder collectionFolder) + { + return collectionFolder.PhysicalFolderIds; + } + + if (folder is not UserView view) + { + return [folder.Id]; + } + + // Only a view that stands for a library proxies it. The sub-views a movie or show view + // is built from hang off the same library but hold a query, not the library's children. + if (!UserView.EnableOriginalFolder(view.ViewType) + && view.ViewType is not (CollectionType.movies or CollectionType.tvshows)) + { + return []; + } + + // A view over a single library proxies that library, whatever the view type. + var parentId = view.DisplayParentId.IsEmpty() ? view.ParentId : view.DisplayParentId; + if (!parentId.IsEmpty() + && !parentId.Equals(view.Id) + && _libraryManager.GetItemById(parentId) is Folder parent + && parent is not UserView) + { + return GetChildCountSourceIds(parent, user); + } + + // A grouped view has no single parent: it stands for every library the user grouped + // into it, the same set UserViewManager builds the view from. + if (view.ViewType is CollectionType.movies or CollectionType.tvshows) + { + return _libraryManager.GetUserRootFolder() + .GetChildren(user, true) + .OfType() + .Where(f => user.IsFolderGrouped(f.Id) + && (f.CollectionType == view.ViewType || f.CollectionType is null)) + .SelectMany(f => f.PhysicalFolderIds) + .Distinct() + .ToList(); + } + + return []; + } + + private int GetChildCount(Folder folder, User user, Dictionary? childCountBatch) + { // Use pre-fetched batch data if available if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count)) { return count; } - // Only reached when no batch was computed: the batch holds an entry for every folder it - // was asked about, zero included. - return folder.GetChildCount(user); + // No batch covered this folder. + var single = GetChildCountBatch([folder], user); + return single is not null && single.TryGetValue(folder.Id, out var singleCount) + ? singleCount + : folder.GetChildCount(user); } private static void SetBookProperties(BaseItemDto dto, Book item) diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 47b3891901..231dbe6429 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,7 +60,16 @@ namespace Emby.Server.Implementations.Library var folderViewType = collectionFolder?.CollectionType; // Playlist and BoxSet libraries require special handling because the folder only references linked items - if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) + if (folderViewType == CollectionType.boxsets) + { + // Only the existence of one visible box set matters here, so probe the children + // lazily and stop at the first hit. + if (!folder.Children.Any(item => item.IsVisible(user))) + { + continue; + } + } + else if (folderViewType == CollectionType.playlists) { var items = folder.GetItemList(new InternalItemsQuery(user) { diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 8b359c48af..a934e5dbf8 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -90,7 +90,7 @@ public class UserViewsController : BaseJellyfinApiController var dtoOptions = new DtoOptions(); dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; - var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); + var dtos = _dtoService.GetBaseItemDtos(folders, dtoOptions, user, skipVisibilityCheck: true); return new QueryResult(dtos); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 8ac6722eef..92f3a4fceb 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -652,24 +652,26 @@ public sealed partial class BaseItemRepository { var maxScore = maxRating.Score; var maxSubScore = maxRating.SubScore ?? 0; - var linkedChildren = context.LinkedChildren; + + // Only a manual link makes an item a container of other items. + var members = context.LinkedChildren + .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual); return e => - // Item has a rating: check against limit - (e.InheritedParentalRatingValue != null - && (e.InheritedParentalRatingValue < maxScore - || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))) - // Item has no rating - || (e.InheritedParentalRatingValue == null - && ( - // No linked children (not a BoxSet/Playlist): pass as unrated - !linkedChildren.Any(lc => lc.ParentId == e.Id) - // Has linked children: at least one child must be within limits - || linkedChildren.Any(lc => lc.ParentId == e.Id - && (lc.Child!.InheritedParentalRatingValue == null - || lc.Child.InheritedParentalRatingValue < maxScore - || (lc.Child.InheritedParentalRatingValue == maxScore - && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))))); + // The item's own rating, where it has one, has to be within the limit. An unrated item + // passes here; blocking those is what BlockUnratedItems does. + (e.InheritedParentalRatingValue == null + || e.InheritedParentalRatingValue < maxScore + || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)) + // A container is only as visible as its members: a BoxSet or Playlist with nothing left + // in it for this user is hidden whatever rating it carries itself. BoxSet.IsVisible + // applies the same rule in memory, and a count has to agree with the listing it counts. + && (!members.Any(lc => lc.ParentId == e.Id) + || members.Any(lc => lc.ParentId == e.Id + && (lc.Child!.InheritedParentalRatingValue == null + || lc.Child.InheritedParentalRatingValue < maxScore + || (lc.Child.InheritedParentalRatingValue == maxScore + && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))); } /// diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 57705cdf11..f8903127b9 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -483,7 +483,19 @@ public class ItemCountService : IItemCountService var includeVirtual = user is null || user.DisplayMissingEpisodes; - var hierarchicalCounts = dbContext.BaseItems + var accessibleItems = dbContext.BaseItems.AsNoTracking(); + if (user is null) + { + // Access filtering is what would otherwise drop an alternate version, and a child count + // must not report a title twice just because no user was passed in. + accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem); + } + else + { + accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user)); + } + + var hierarchicalCounts = accessibleItems .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value) .GroupBy(b => b.ParentId!.Value) @@ -493,20 +505,22 @@ public class ItemCountService : IItemCountService // An episode is a child of its season even when it is not stored under one: with a flat // structure ParentId points at the series, so counting by ParentId alone leaves the season // empty and counts its episodes towards the series instead. - var seasonCounts = dbContext.BaseItems + var seasonCounts = accessibleItems .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value) .GroupBy(b => b.SeasonId!.Value) .Select(g => new { SeasonId = g.Key, Count = g.Count() }) .ToDictionary(x => x.SeasonId, x => x.Count); + // A linked child counts only when the item it points at is one the user may open. var linkedCounts = dbContext.LinkedChildren .WhereOneOrMany(parentIdsArray, lc => lc.ParentId) - .GroupBy(lc => lc.ParentId) + .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId) + .GroupBy(parentId => parentId) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); - var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual); + var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual); var result = new Dictionary(); foreach (var parentId in parentIds) @@ -527,7 +541,11 @@ public class ItemCountService : IItemCountService return result; } - private static Dictionary GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList parentIds, bool includeVirtual) + private static Dictionary GetMergedChildCounts( + JellyfinDbContext dbContext, + IQueryable accessibleItems, + IReadOnlyList parentIds, + bool includeVirtual) { var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) .Where(group => group.Value.Count > 1) @@ -540,14 +558,12 @@ public class ItemCountService : IItemCountService // Only merged folders. var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); - var children = dbContext.BaseItems - .AsNoTracking() + var children = accessibleItems .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(memberIds, b => b.ParentId!.Value) .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) .ToArray() - .Concat(dbContext.BaseItems - .AsNoTracking() + .Concat(accessibleItems .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(memberIds, b => b.SeasonId!.Value) .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey }) diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs index f478daef23..d3c7ecb0ad 100644 --- a/Jellyfin.Server.Implementations/Item/NextUpService.cs +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -129,12 +129,21 @@ public class NextUpService : INextUpService // Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation. // SelectMany on UserData with a correlated Where would translate to APPLY, // which SQLite does not support. + // Access filtering leaves only primaries in the base query, but a play can be recorded + // against any version, so each row is attributed to its group's primary before the join. + var playedByGroupPrimary = context.UserData + .AsNoTracking() + .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) + .Where(ud => ud.Played) + .Join( + context.BaseItems.AsNoTracking(), + ud => ud.ItemId, + bi => bi.Id, + (ud, bi) => new { ud.UserId, ItemId = bi.PrimaryVersionId ?? bi.Id, ud.LastPlayedDate }); + var playedWithDates = lastWatchedByDateBase .Join( - context.UserData - .AsNoTracking() - .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) - .Where(ud => ud.Played), + playedByGroupPrimary, e => new { UserId = userId, ItemId = e.Id }, ud => new { ud.UserId, ud.ItemId }, (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }) diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 8216937cad..d7f9102be9 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -168,14 +168,21 @@ namespace MediaBrowser.Controller.Entities.Movies return true; } - var userLibraryFolderIds = GetLibraryFolderIds(user); - var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + List linkedItems = null; + var libraryFolderIds = LibraryFolderIds; + if (libraryFolderIds is null) + { + linkedItems = GetLinkedChildren(); + libraryFolderIds = GetLibraryFolderIds(linkedItems); + } if (libraryFolderIds.Length == 0) { return true; } + var userLibraryFolderIds = GetLibraryFolderIds(user); + if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i))) { return false; @@ -184,7 +191,7 @@ namespace MediaBrowser.Controller.Entities.Movies // If user has parental controls, hide the BoxSet when all children are restricted if (user.MaxParentalRatingScore.HasValue) { - var linkedItems = GetLinkedChildren(); + linkedItems ??= GetLinkedChildren(); if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { return false; @@ -241,10 +248,19 @@ namespace MediaBrowser.Controller.Entities.Movies public Guid[] GetLibraryFolderIds() { - var expandedFolders = new List(); + return GetLibraryFolderIds(GetLinkedChildren()); + } + + private Guid[] GetLibraryFolderIds(IEnumerable linkedChildren) + { + // Seeded with this box set so a cycle through a nested collection terminates. + var expandedFolders = new List { Id }; + + // The user root children are the same for every item. + var rootChildren = LibraryManager.GetUserRootFolder().Children.OfType().ToList(); - return FlattenItems(this, expandedFolders) - .SelectMany(LibraryManager.GetCollectionFolders) + return FlattenItems(linkedChildren, expandedFolders) + .SelectMany(i => LibraryManager.GetCollectionFolders(i, rootChildren)) .Select(i => i.Id) .Distinct() .ToArray(); diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index f9ad2d86e6..82256cd964 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -455,25 +455,34 @@ namespace MediaBrowser.Controller.Entities { var itemList = filtered.ToList(); var folderIds = itemList.OfType().Select(f => f.Id).ToList(); + var leaves = itemList.Where(i => i is not Folder).ToList(); + var isPlayedValue = query.IsPlayed.Value; - if (folderIds.Count > 0) - { - var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); - var isPlayedValue = query.IsPlayed.Value; + var counts = folderIds.Count > 0 + ? libraryManager.GetPlayedAndTotalCountBatch(folderIds, user) + : null; + + // A movie held as several files is watched once any of its versions is watched. + var resumeData = leaves.Count > 0 + ? userDataManager.GetResumeUserDataBatch(leaves, user) + : null; - return itemList.Where(item => + return itemList.Where(item => + { + if (item is Folder) { - if (item is Folder) - { - var itemCount = counts.GetValueOrDefault(item.Id); - return (itemCount.Played >= itemCount.Total) == isPlayedValue; - } + var itemCount = counts?.GetValueOrDefault(item.Id) ?? default; + return (itemCount.Played >= itemCount.Total) == isPlayedValue; + } - return true; - }); - } + var played = userDataManager.GetUserData(user, item)?.Played ?? false; + if (!played && resumeData is not null && resumeData.TryGetValue(item.Id, out var versionData)) + { + played = versionData.UserData.Played; + } - return itemList; + return played == isPlayedValue; + }); } return filtered; @@ -606,19 +615,7 @@ namespace MediaBrowser.Controller.Entities } } - if (query.IsPlayed.HasValue) - { - // Folder.IsPlayed() hits the DB per-item (N+1 queries). - // Folders are batch-filtered by the collection Filter() overload. - if (!item.IsFolder) - { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) - { - return false; - } - } - } + // IsPlayed is answered by the collection Filter() overload for folders and leaves alike. if (query.IsLocked.HasValue) { diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index fc367b8293..edf3fb25c9 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -235,18 +235,18 @@ namespace MediaBrowser.Controller.Playlists { if (!IsSharedItem) { - return base.IsVisible(user, skipAllowedTagsCheck); + return base.IsVisible(user, skipAllowedTagsCheck) && HasParentalAllowedChild(user); } if (OpenAccess) { - return true; + return HasParentalAllowedChild(user); } var userId = user.Id; if (userId.Equals(OwnerUserId)) { - return true; + return HasParentalAllowedChild(user); } var shares = Shares; @@ -255,7 +255,19 @@ namespace MediaBrowser.Controller.Playlists return false; } - return shares.Any(s => s.UserId.Equals(userId)); + return shares.Any(s => s.UserId.Equals(userId)) && HasParentalAllowedChild(user); + } + + private bool HasParentalAllowedChild(User user) + { + if (!user.MaxParentalRatingScore.HasValue) + { + return true; + } + + var linkedItems = GetLinkedChildren(); + + return linkedItems.Count == 0 || linkedItems.Any(child => child.IsParentalAllowed(user, true)); } public override bool CanDelete(User user) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index b821476390..2ba3faf5f7 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -21,6 +21,65 @@ public static class DescendantQueryHelper public static Expression> IsCountableLeaf { get; } = b => !b.IsFolder && !b.IsVirtualItem; + /// + /// Gets the predicate identifying the items that stand on their own in a library. An alternate + /// version is a second file for the item that links it rather than an item beside it, and an owned + /// item belongs to its owner unless it is an extra (a trailer and the like, which carries both an + /// owner and an extra type). Nothing here turns on who is asking, so a count that applies it + /// answers the same with a user and without one. + /// + public static Expression> IsDistinctLibraryItem { get; } = + b => !b.PrimaryVersionId.HasValue && (!b.OwnerId.HasValue || b.ExtraType != null); + + /// + /// Builds the predicate identifying the items a user has played, counting a multi-version item as + /// played when any of its alternate versions is. Mirrors the aggregation + /// VersionResumeData.ApplyTo performs on the played flag a single item reports, so that a + /// folder's unplayed count cannot disagree with the watched state its members render with. + /// + /// The id of the user whose played state to test. + /// The predicate matching the items that user has played. + public static Expression> IsPlayedBy(Guid userId) => + b => b.UserData!.Any(u => u.UserId.Equals(userId) && u.Played) + || b.LinkedChildEntities!.Any(lc => + (lc.ChildType == LinkedChildType.LocalAlternateVersion || lc.ChildType == LinkedChildType.LinkedAlternateVersion) + && lc.Child!.UserData!.Any(u => u.UserId.Equals(userId) && u.Played)); + + /// + /// Builds the projection pairing an item's id with evaluated on that same + /// row. A caller that needs the flag alongside the id composes it rather than testing membership of + /// the played set: as a sub-select the set is unbounded by whatever the caller joins it to, so the + /// database builds it from the whole table once per place it appears. + /// + /// The id of the user whose played state to test. + /// The projection of each item onto its id and that user's played state. + public static Expression> PlayedStateBy(Guid userId) + { + var played = IsPlayedBy(userId); + var item = played.Parameters[0]; + + // Named members, as the compiler emits for an anonymous type: without them the query provider + // cannot read a later `x.Id` back to the column it was built from and gives up translating. + return Expression.Lambda>( + Expression.New( + typeof(LeafPlayedState).GetConstructor([typeof(Guid), typeof(bool)])!, + [Expression.Property(item, nameof(BaseItemEntity.Id)), played.Body], + [typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Id))!, typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Played))!]), + item); + } + + /// + /// Builds the negation of , so a caller filtering for unplayed items reads + /// the same definition of played as one filtering for played items. + /// + /// The id of the user whose played state to test. + /// The predicate matching the items that user has not played. + public static Expression> IsUnplayedBy(Guid userId) + { + var played = IsPlayedBy(userId); + return Expression.Lambda>(Expression.Not(played.Body), played.Parameters); + } + /// /// Gets a queryable of all descendant IDs for a parent item. /// Traverses AncestorIds and LinkedChildren to find all descendants. diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs new file mode 100644 index 0000000000..70da5eafe5 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Linq; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Model.Querying; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +public class PlaylistTests +{ + [Fact] + public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden() + { + // The SQL parental filter hides a container whose every member is blocked, so a listing + // built in memory has to reach the same answer. + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + SetupLibrary(blocked); + + Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" }; + SetupLibrary(blocked, allowed); + + Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var libraryManager = SetupLibrary(blocked); + var user = new User("user", "auth-provider", "reset-provider"); + + Assert.True(BuildPlaylist(blocked).IsVisible(user)); + + // Resolving a playlist's items is a query per playlist; nothing may run it for a user no + // rating keeps anything from. + libraryManager.Verify(x => x.GetItemList(It.IsAny()), Times.Never); + } + + private static Mock SetupLibrary(params BaseItem[] items) + { + var libraryManager = new Mock(); + libraryManager + .Setup(x => x.GetItemList(It.IsAny())) + .Returns(items); + BaseItem.LibraryManager = libraryManager.Object; + + return libraryManager; + } + + private static Playlist BuildPlaylist(params BaseItem[] items) + { + // An empty path keeps the playlist out of the shared-playlist branch. + return new Playlist + { + Id = Guid.NewGuid(), + Name = "Playlist", + LinkedChildren = items.Select(LinkedChild.Create).ToArray() + }; + } + + private static User BuildRestrictedUser() + { + var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 }; + user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie }); + + return user; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index 679e6d17e3..fd84cfb497 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; using Emby.Server.Implementations.Dto; +using Jellyfin.Data; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; @@ -138,6 +142,96 @@ public class DtoServiceTests Assert.Equal(9, dto.ChildCount); } + [Fact] + public void GetBaseItemDtos_NoUser_SkipsTheChildCountBatch() + { + // A child count is attached only to a user's dto, so with no user the batch is work whose + // result nothing reads - and it is a grouped count over every item, not a cheap one. + var (season, _) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10); + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + var dto = _dtoService.GetBaseItemDtos([season], options, user: null, skipVisibilityCheck: true)[0]; + + Assert.Null(dto.ChildCount); + _libraryManagerMock.Verify( + x => x.GetChildCountBatch(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public void GetBaseItemDtos_GroupedMoviesView_CountsEveryLibraryGroupedIntoIt() + { + // The view has no library of its own, so its count is the sum over the libraries the user + // grouped into it - including an untyped one, which the view also shows. + var user = new User("user", "auth-provider", "reset-provider"); + var grouped = BuildLibrary(CollectionType.movies); + var untyped = BuildLibrary(null); + var shows = BuildLibrary(CollectionType.tvshows); + var ungrouped = BuildLibrary(CollectionType.movies); + user.SetPreference(PreferenceKind.GroupedFolders, [grouped.Id, untyped.Id, shows.Id]); + + // A real root folder would resolve its children through the library it does not have here. + var rootFolder = new Mock(); + rootFolder + .Setup(x => x.GetChildren(user, true, It.IsAny())) + .Returns((_, _, _) => [grouped, untyped, shows, ungrouped]); + _libraryManagerMock.Setup(x => x.GetUserRootFolder()).Returns(rootFolder.Object); + + IReadOnlyList? counted = null; + _libraryManagerMock + .Setup(x => x.GetChildCountBatch(It.IsAny>(), It.IsAny())) + .Callback, User?>((ids, _) => counted = ids) + .Returns, User?>((ids, _) => ids.ToDictionary(id => id, _ => 4)); + + var view = new UserView { Id = Guid.NewGuid(), Name = "Movies", ViewType = CollectionType.movies }; + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + var dto = _dtoService.GetBaseItemDtos([view], options, user, skipVisibilityCheck: true)[0]; + + Assert.Equal(grouped.PhysicalFolderIds.Concat(untyped.PhysicalFolderIds), counted); + Assert.Equal(16, dto.ChildCount); + } + + [Fact] + public void GetBaseItemDtos_SubViewOfALibrary_DoesNotCountTheLibrary() + { + // A sub-view hangs off the library the view was built over, but it holds a query over it, + // not its children: counting the library would report every movie as "Continue Watching". + var user = new User("user", "auth-provider", "reset-provider"); + var library = BuildLibrary(CollectionType.movies); + _libraryManagerMock.Setup(x => x.GetItemById(library.Id)).Returns(library); + + // The fallback count a sub-view falls through to runs a query of its own. + _libraryManagerMock + .Setup(x => x.GetItemList(It.IsAny())) + .Returns([]); + + var subView = new UserView + { + Id = Guid.NewGuid(), + Name = "Continue Watching", + ViewType = CollectionType.movieresume, + DisplayParentId = library.Id + }; + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + _dtoService.GetBaseItemDtos([subView], options, user, skipVisibilityCheck: true); + + _libraryManagerMock.Verify( + x => x.GetChildCountBatch(It.IsAny>(), It.IsAny()), + Times.Never); + } + + private static CollectionFolder BuildLibrary(CollectionType? collectionType) + { + return new CollectionFolder + { + Id = Guid.NewGuid(), + CollectionType = collectionType, + PhysicalFolderIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + } + private (Season Season, User User) BuildSeason(int playedCount, int totalCount, int childCount) { var user = new User("user", "auth-provider", "reset-provider"); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs new file mode 100644 index 0000000000..54fec0a0d3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Entities; + +public sealed class UserViewBuilderTests +{ + private static readonly User _user = new("view-filter-test", "provider", "reset"); + + [Fact] + public void Filter_IsPlayed_CountsAMovieWatchedOnAnAlternateVersionAsPlayed() + { + // The primary carries no played row of its own; the version that was watched is another file. + var onlyWatchedOnAlternate = new Movie { Id = Guid.NewGuid(), Name = "Watched as a second cut" }; + var watched = new Movie { Id = Guid.NewGuid(), Name = "Watched outright" }; + var unwatched = new Movie { Id = Guid.NewGuid(), Name = "Not watched" }; + + var items = new BaseItem[] { onlyWatchedOnAlternate, watched, unwatched }; + + var userDataManager = new Mock(); + userDataManager + .Setup(m => m.GetUserData(_user, It.IsAny())) + .Returns((User _, BaseItem item) => new UserItemData { Key = item.Id.ToString("N"), Played = item.Id.Equals(watched.Id) }); + userDataManager + .Setup(m => m.GetResumeUserDataBatch(It.IsAny>(), _user)) + .Returns(new Dictionary + { + [onlyWatchedOnAlternate.Id] = new(Guid.NewGuid(), new UserItemData { Key = "alternate", Played = true }) + }); + + var libraryManager = new Mock(); + + var played = UserViewBuilder.Filter( + items, + _user, + new InternalItemsQuery(_user) { IsPlayed = true }, + userDataManager.Object, + libraryManager.Object).ToList(); + + var unplayed = UserViewBuilder.Filter( + items, + _user, + new InternalItemsQuery(_user) { IsPlayed = false }, + userDataManager.Object, + libraryManager.Object).ToList(); + + // The alternate's playback settles the movie, exactly as the item's own dto reports it. + Assert.Equal([onlyWatchedOnAlternate.Id, watched.Id], played.Select(i => i.Id)); + Assert.Equal([unwatched.Id], unplayed.Select(i => i.Id)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index ff683dc57a..787bb24150 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -200,6 +200,201 @@ public sealed class ItemCountServiceTests : IDisposable Assert.Equal((0, 1), _service.GetPlayedAndTotalCount(filter, loneSeriesId)); } + [Fact] + public void GetCounts_PlayedAlternateVersion_CountThePrimaryAsPlayed() + { + var user = new User("alt-version-test", "provider", "reset"); + var seriesId = Guid.NewGuid(); + var primaryId = Guid.NewGuid(); + var alternateId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + var series = CreateItem(seriesId); + series.PresentationUniqueKey = "alt-version-series"; + context.BaseItems.Add(series); + + context.BaseItems.Add(CreateLeaf(primaryId)); + var alternate = CreateLeaf(alternateId); + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + context.SaveChanges(); + + // Only the primary is counted as a leaf, as ApplyAccessFiltering leaves it in production. + AddAncestor(context, primaryId, seriesId); + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = primaryId, + ChildId = alternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + // The file that was watched is the alternate, so the primary carries no played row. + context.UserData.Add(new UserData + { + ItemId = alternateId, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + // The per-item paths have to agree with the batch one, which the DTO uses interchangeably. + Assert.Equal(1, _service.GetPlayedCount(filter, seriesId)); + Assert.Equal((1, 1), _service.GetPlayedAndTotalCount(filter, seriesId)); + Assert.Equal((1, 1), _service.GetPlayedAndTotalCountBatch([seriesId], user)[seriesId]); + } + + [Fact] + public void GetCounts_MultiVersionMovie_CountPlaybackOfAnyVersion() + { + // Two movies held as two files each: the primary the collection links, and an alternate version + // linked to it. One movie was watched on its alternate, which is where playback of a second cut + // lands; the other was not watched at all. + var user = new User("alt-version-test", "provider", "reset"); + var boxSetId = Guid.NewGuid(); + var libraryId = Guid.NewGuid(); + var watchedPrimaryId = Guid.NewGuid(); + var watchedAlternateId = Guid.NewGuid(); + var unwatchedPrimaryId = Guid.NewGuid(); + var unwatchedAlternateId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + var boxSet = CreateItem(boxSetId); + boxSet.PresentationUniqueKey = "alt-version-box-set"; + context.BaseItems.Add(boxSet); + + var library = CreateItem(libraryId); + library.PresentationUniqueKey = "alt-version-library"; + context.BaseItems.Add(library); + + foreach (var (primaryId, alternateId) in + new[] { (watchedPrimaryId, watchedAlternateId), (unwatchedPrimaryId, unwatchedAlternateId) }) + { + context.BaseItems.Add(CreateLeaf(primaryId)); + + var alternate = CreateLeaf(alternateId); + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + } + + context.SaveChanges(); + + context.LinkedChildren.AddRange( + new LinkedChildEntity + { + ParentId = boxSetId, + ChildId = watchedPrimaryId, + ChildType = LinkedChildType.Manual, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = boxSetId, + ChildId = unwatchedPrimaryId, + ChildType = LinkedChildType.Manual, + SortOrder = 1 + }, + new LinkedChildEntity + { + ParentId = watchedPrimaryId, + ChildId = watchedAlternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = unwatchedPrimaryId, + ChildId = unwatchedAlternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + AddAncestor(context, watchedPrimaryId, libraryId); + AddAncestor(context, unwatchedPrimaryId, libraryId); + + context.UserData.Add(new UserData + { + ItemId = watchedAlternateId, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + // A version group is one item to count, and the alternate's playback makes that item played - + // as it already does for the played flag the primary itself reports. + Assert.Equal((1, 2), _service.GetPlayedAndTotalCountFromLinkedChildren(filter, boxSetId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCountBatch([boxSetId], user)[boxSetId]); + + // The ancestor-based paths answer the same for the library the primaries sit in. + Assert.Equal(1, _service.GetPlayedCount(filter, libraryId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, libraryId)); + } + + [Fact] + public void GetChildCountBatch_NoUser_StillCollapsesAlternateVersions() + { + // Both files of a merged movie sit in the folder. With a user it is access filtering that + // drops the alternate; with no user nothing else would, and the folder would report two + // children for the one title a viewer sees. + var folderId = Guid.NewGuid(); + var primaryId = Guid.NewGuid(); + var alternateId = Guid.NewGuid(); + var extraId = Guid.NewGuid(); + var ownedId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.Add(CreateItem(folderId)); + + var primary = CreateLeaf(primaryId); + primary.ParentId = folderId; + context.BaseItems.Add(primary); + + var alternate = CreateLeaf(alternateId); + alternate.ParentId = folderId; + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + + // An extra carries an owner and an extra type, and stays a child of its own. + var extra = CreateLeaf(extraId); + extra.ParentId = folderId; + extra.OwnerId = primaryId; + extra.ExtraType = BaseItemExtraType.Trailer; + context.BaseItems.Add(extra); + + // An owned item that is not an extra belongs to its owner, not to the folder. + var owned = CreateLeaf(ownedId); + owned.ParentId = folderId; + owned.OwnerId = primaryId; + context.BaseItems.Add(owned); + + context.SaveChanges(); + } + + Assert.Equal(2, _service.GetChildCountBatch([folderId], null)[folderId]); + } + [Fact] public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys() { -- cgit v1.2.3 From b188071931a15ec4db8eb96fe1e24d98f4e48068 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:15:53 -0400 Subject: Backport pull request #17898 from jellyfin/release-12.z Populate missing tags array in Filters2 Original-merge: 3d52fbe84e78d6447465bb831039dabb6142ca67 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 12 ++++++ Jellyfin.Api/Controllers/FilterController.cs | 21 +++++++++++ .../Item/BaseItemRepository.ByName.cs | 29 ++++++++++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 7 ++++ .../Persistence/IItemRepository.cs | 7 ++++ .../Item/BaseItemRepositoryItemValueTests.cs | 44 ++++++++++++++++++++++ 6 files changed, 120 insertions(+) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 0044fcd4dc..f97cc7a0a0 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -4127,6 +4127,18 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetQueryFiltersLegacy(query); } + /// + public IReadOnlyList GetTagNames(InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetTagNames(query); + } + /// public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType) { diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index b458bd90f3..b9ca45cc1c 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -178,16 +178,35 @@ public class FilterController : BaseJellyfinApiController IsSeries = isSeries }; + var tagQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions + { + Fields = Array.Empty(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) { var ancestorIds = parentItem is null ? Array.Empty() : new[] { parentItem.Id }; genreQuery.AncestorIds = ancestorIds; streamLanguageQuery.AncestorIds = ancestorIds; + tagQuery.AncestorIds = ancestorIds; } else { genreQuery.Parent = parentItem; streamLanguageQuery.Parent = parentItem; + tagQuery.Parent = parentItem; } if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season)) @@ -218,6 +237,8 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } + filters.Tags = _libraryManager.GetTagNames(tagQuery); + if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index cdc8744642..51ac146a6f 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -117,6 +117,35 @@ public sealed partial class BaseItemRepository .ToArray(); } + /// + public IReadOnlyList GetTagNames(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); + + var matchingItemIds = baseQuery.Select(e => e.Id); + + // Project the join before grouping. Grouping over the ItemValue navigation instead makes EF + // re-resolve the aggregate as a correlated subquery per group, which is orders of magnitude slower. + return context.ItemValuesMap + .AsNoTracking() + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Tags) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) + .GroupBy(iv => iv.CleanValue) + .Select(g => g.Min(iv => iv.Value)!) + .OrderBy(t => t) + .ToArray(); + } + private string[] GetItemValueNames(IReadOnlyList itemValueTypes, IReadOnlyList withItemTypes, IReadOnlyList excludeItemTypes) { using var context = _dbProvider.CreateDbContext(); diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index ac6df54949..82de3546f0 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -832,6 +832,13 @@ namespace MediaBrowser.Controller.Library /// Aggregated filter values. QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); + /// + /// Gets a list of all distinct tags of the matching items. + /// + /// The query filter. + /// List of tags. + IReadOnlyList GetTagNames(InternalItemsQuery query); + /// /// Gets a list of all language codes of the provided stream type. /// diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index d44fe57bed..3cf06b897c 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -128,6 +128,13 @@ public interface IItemRepository /// List of language codes. public IReadOnlyList GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType); + /// + /// Gets all distinct tags of the matching base items. + /// + /// The query filter. + /// The list of tags. + IReadOnlyList GetTagNames(InternalItemsQuery filter); + /// /// Gets all artist names. /// diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs index 91148501ce..039693c432 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs @@ -103,6 +103,50 @@ public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture Assert.Equal(["Genre Leak"], result.Genres); } + [Fact] + public void GetTagNames_GroupsAndFiltersItemValues() + { + var movie = CreateMovieEntity(Guid.NewGuid(), "Movie"); + var otherMovie = CreateMovieEntity(Guid.NewGuid(), "Other Movie"); + var audio = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Excluded Audio", + MediaType = "Audio", + IsMovie = false, + IsFolder = false, + IsVirtualItem = false + }; + var tag = CreateItemValue(ItemValueType.Tags, "Alpha", "alpha"); + var duplicateTag = CreateItemValue(ItemValueType.Tags, "alpha", "alpha"); + var otherTag = CreateItemValue(ItemValueType.Tags, "Beta", "beta"); + var inheritedTag = CreateItemValue(ItemValueType.InheritedTags, "Inherited", "inherited"); + var genre = CreateItemValue(ItemValueType.Genre, "Genre Leak", "genre leak"); + var excludedTag = CreateItemValue(ItemValueType.Tags, "Excluded Tag", "excluded tag"); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(movie, otherMovie, audio); + context.ItemValues.AddRange(tag, duplicateTag, otherTag, inheritedTag, genre, excludedTag); + context.ItemValuesMap.AddRange( + CreateMap(movie, tag), + CreateMap(movie, duplicateTag), + CreateMap(otherMovie, otherTag), + CreateMap(movie, inheritedTag), + CreateMap(movie, genre), + CreateMap(audio, excludedTag)); + context.SaveChanges(); + } + + var result = _repository.GetTagNames(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal(["Alpha", "Beta"], result); + } + [Fact] public void GetGenreNames_GroupsAndFiltersMappedItemValues() { -- cgit v1.2.3 From 7d5d4487421f0351f95069f6cd5fc6bd986f780a Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:16:17 -0400 Subject: Backport pull request #17982 from jellyfin/release-12.z Fix SimilarItemsManager concurrency Original-merge: 47ba387eb36458e2b417748fbe852c27957020ad Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/SimilarItems/SimilarItemsManager.cs | 29 ++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index fd5f292ebe..a18a17b593 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -651,7 +651,13 @@ public class SimilarItemsManager : ISimilarItemsManager try { - var stream = File.OpenRead(cachePath); + var stream = new FileStream( + cachePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); await using (stream.ConfigureAwait(false)) { var cache = await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); @@ -675,6 +681,7 @@ public class SimilarItemsManager : ISimilarItemsManager private async Task SaveSimilarItemsCacheAsync(string cachePath, List references, TimeSpan cacheDuration, CancellationToken cancellationToken) { + string? tempPath = null; try { var directory = Path.GetDirectoryName(cachePath); @@ -689,16 +696,34 @@ public class SimilarItemsManager : ISimilarItemsManager ExpiresAt = DateTime.UtcNow.Add(cacheDuration) }; - var stream = File.Create(cachePath); + tempPath = cachePath + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp"; + var stream = File.Create(tempPath); await using (stream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); } + + File.Move(tempPath, cachePath, true); + tempPath = null; } catch (IOException ex) { _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); } + finally + { + if (tempPath is not null) + { + try + { + File.Delete(tempPath); + } + catch (IOException ex) + { + _logger.LogDebug(ex, "Failed to delete temporary similar items cache file {TempPath}", tempPath); + } + } + } } private sealed class SimilarItemsCache -- cgit v1.2.3 From d8aa558655f3b0e8c3edd77528b6be65b73b39ed Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:16:19 -0400 Subject: Backport pull request #17998 from jellyfin/release-12.z Fix forced-only not overriding remembered subtitles Original-merge: 95d7028f3afdd4419c664e917f43bee685b0ab7e Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/MediaSourceManager.cs | 7 ++- .../Library/MediaSourceManagerTests.cs | 69 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 97e00177b6..8e428452f3 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -494,7 +494,12 @@ namespace Emby.Server.Implementations.Library { var index = userData.SubtitleStreamIndex.Value; // Make sure the saved index is still valid - if (index == -1 || source.MediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.Index == index)) + var savedStream = source.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + // "Only forced" rules out full tracks entirely, so a remembered one must not resurrect them. + // The client reports whatever is playing, so an index remembered under another mode sticks forever otherwise. + if (index == -1 + || (savedStream is not null + && (user.SubtitleMode != SubtitlePlaybackMode.OnlyForced || savedStream.IsForced))) { source.DefaultSubtitleStreamIndex = index; return; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index c80f899498..685b769f34 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -7,7 +7,9 @@ using Castle.Components.DictionaryAdapter; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaSegments; @@ -149,6 +151,73 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex); } + [Theory] + // A remembered full track must not survive a switch to "only forced" (it falls through to + // the forced track here); a remembered forced track and "off" still must. + [InlineData(SubtitlePlaybackMode.OnlyForced, 2, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, 3, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, -1, -1)] + [InlineData(SubtitlePlaybackMode.Default, 2, 2)] + [InlineData(SubtitlePlaybackMode.Always, 2, 2)] + [InlineData(SubtitlePlaybackMode.Smart, 2, 2)] + [InlineData(SubtitlePlaybackMode.None, 2, null)] + public void SetDefaultSubtitleStreamIndex_RememberedSelection_RespectsSubtitleMode( + SubtitlePlaybackMode mode, + int rememberedIndex, + int? expectedIndex) + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny(), It.IsAny())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = rememberedIndex }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false }, + new() { Index = 3, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = false, IsForced = true } + } + }; + + _user.SubtitleMode = mode; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Equal(expectedIndex, mediaInfo.DefaultSubtitleStreamIndex); + } + + [Fact] + public void SetDefaultSubtitleStreamIndex_OnlyForcedRemembersFullTrackWithNoForcedStream_SelectsNothing() + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny(), It.IsAny())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = 2 }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false } + } + }; + + _user.SubtitleMode = SubtitlePlaybackMode.OnlyForced; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Null(mediaInfo.DefaultSubtitleStreamIndex); + } + [Fact] public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion() { -- cgit v1.2.3 From 6937f03dc2c719b730f092fe4a87d8d4b6c2fc93 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:16:20 -0400 Subject: Backport pull request #17999 from jellyfin/release-12.z Don't queue by-name items for playback and reject source requests for them Original-merge: d3b2542f2193e0350e1dd88a2f3395fe8a9ca6f4 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/MediaSourceManager.cs | 8 +- .../Session/SessionManager.cs | 4 +- .../Library/MediaSourceManagerTests.cs | 8 ++ .../SessionManager/PlayCommandQueueTests.cs | 93 ++++++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 8e428452f3..e9bba05839 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -384,7 +384,13 @@ namespace Emby.Server.Implementations.Library { ArgumentNullException.ThrowIfNull(item); - var hasMediaSources = (IHasMediaSources)item; + // Clients can ask for the sources of an item that has none (a container queued by mistake). + if (item is not IHasMediaSources hasMediaSources) + { + throw new ArgumentException( + string.Format(CultureInfo.InvariantCulture, "{0} {1} has no media sources and cannot be played.", item.GetType().Name, item.Id), + nameof(item)); + } var sources = hasMediaSources.GetMediaSources(enablePathSubstitution); diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 94215bed79..08e2578867 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1449,6 +1449,8 @@ namespace Emby.Server.Implementations.Session if (item is IItemByName byName) { + // A by-name item tags containers as well as leaves: a music genre tags its artists, + // and a by-name artist row is not a folder, so IsFolder does not exclude it here. return byName.GetTaggedItems(new InternalItemsQuery(user) { IsFolder = false, @@ -1463,7 +1465,7 @@ namespace Emby.Server.Implementations.Session }, IsVirtualItem = false, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } - }); + }).Where(i => i is not IItemByName); } if (item.IsFolder) diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index 685b769f34..131cb23fa4 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -263,6 +263,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.Equal(primary.Id.ToString("N"), sources[0].Id); } + [Fact] + public void GetStaticMediaSources_ItemWithoutMediaSources_ThrowsArgumentException() + { + // A container queued by mistake is a bad request, not a server fault. + Assert.Throws( + () => _mediaSourceManager.GetStaticMediaSources(new MusicArtist { Id = Guid.NewGuid() }, false, _user)); + } + [Fact] public void GetStaticMediaSources_NoUser_DoesNotTouchUserData() { diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs new file mode 100644 index 0000000000..a07e79baa3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class PlayCommandQueueTests : IDisposable +{ + private readonly ILibraryManager? _previousLibraryManager; + + public PlayCommandQueueTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + } + + /// + /// A music genre tags its artists as well as their songs, and a by-name artist row is not a + /// folder, so the queue query cannot exclude it. Such an item has no media sources, and a + /// client that reaches it in the queue gets an error instead of the next track. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems() + { + var genre = new MusicGenre { Id = Guid.NewGuid(), Name = "Reggaeton" }; + var song = new Audio { Id = Guid.NewGuid(), Name = "Me Porto Bonito" }; + var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "NATTI NATASHA" }; + + var libraryManager = new Mock(); + libraryManager.Setup(i => i.GetItemById(genre.Id)).Returns(genre); + libraryManager + .Setup(i => i.GetItemList(It.IsAny())) + .Returns(new List { artist, song }); + BaseItem.LibraryManager = libraryManager.Object; + + await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( + NullLogger.Instance, + Mock.Of(), + Mock.Of(), + Mock.Of(), + libraryManager.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + var session = await sessionManager.LogSessionActivity("app_name", "0.0.0", "device_id", "device_name", "127.0.0.1", null); + + var command = new PlayRequest + { + ItemIds = new[] { genre.Id }, + PlayCommand = PlayCommand.PlayNow + }; + + await sessionManager.SendPlayCommand(null, session.Id, command, CancellationToken.None); + + Assert.Equal(new[] { song.Id }, command.ItemIds); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + BaseItem.LibraryManager = _previousLibraryManager!; + } + } +} -- cgit v1.2.3 From 7c7244d32fd9290989c523e3f344657d87cfbb7c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:16:54 -0400 Subject: Backport pull request #18003 from jellyfin/release-12.z Check Live TV access for a single user instead of enumerating all users Original-merge: 2bcd3b1efb121ad661495570b43aace247ee0a27 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/UserViewManager.cs | 2 +- .../Item/BaseItemRepository.Querying.cs | 4 +- .../Item/BaseItemRepository.TranslateQuery.cs | 4 +- .../Item/ItemCountService.cs | 6 +- .../Item/NextUpService.cs | 6 +- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 7 ++ .../LeafPlayedState.cs | 10 ++ src/Jellyfin.LiveTv/LiveTvManager.cs | 8 ++ .../Item/BaseItemRepositoryPlayedVersionTests.cs | 64 ++++++++++-- .../Item/NextUpServiceTests.cs | 114 +++++++++++++++++++++ 10 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 231dbe6429..db911cd24a 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -136,7 +136,7 @@ namespace Emby.Server.Implementations.Library list.AddRange(channels); - if (_liveTvManager.GetEnabledUsers().Select(i => i.Id).Contains(user.Id)) + if (_liveTvManager.IsEnabledForUser(user)) { list.Add(_liveTvManager.GetInternalLiveTvFolder(CancellationToken.None)); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index 1ed10cce2b..8d573569e9 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -593,10 +593,10 @@ public sealed partial class BaseItemRepository return dbContext.BaseItems .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem) - .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + .All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } - return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } /// diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 486b3b5de6..d726f0f143 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -581,8 +581,8 @@ public sealed partial class BaseItemRepository .ToArray(); var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type)) .And(BuildHasDescendantFilter(context, inProgressLeafItems) - .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))))); + .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId))) + .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not()))))); if (isResumable) { diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index f8903127b9..942161a176 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -414,7 +414,7 @@ public class ItemCountService : IItemCountService using var dbContext = _dbProvider.CreateDbContext(); var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); - return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); + return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id)); } /// @@ -617,7 +617,7 @@ public class ItemCountService : IItemCountService leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); + .Select(DescendantQueryHelper.PlayedStateBy(userId)); var ancestorLeaves = dbContext.AncestorIds .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) @@ -735,7 +735,7 @@ public class ItemCountService : IItemCountService private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable query, Guid userId) { var result = query - .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) + .Select(DescendantQueryHelper.IsPlayedBy(userId)) .GroupBy(_ => 1) .OrderBy(g => g.Key) .Select(g => new diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs index d3c7ecb0ad..897fb98cbb 100644 --- a/Jellyfin.Server.Implementations/Item/NextUpService.cs +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -95,7 +95,7 @@ public class NextUpService : INextUpService .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter); // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery @@ -207,7 +207,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsUnplayedBy(userId)); allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter); var allUnplayedCandidates = allUnplayedBase .Select(e => new @@ -255,7 +255,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter); var allPlayedCandidates = allPlayedBase .Select(e => new diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 8d59eef9f1..77e0087048 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -200,6 +200,13 @@ namespace MediaBrowser.Controller.LiveTv /// IEnumerable{User}. IEnumerable GetEnabledUsers(); + /// + /// Gets whether Live TV is enabled for a single user. + /// + /// The user. + /// Whether Live TV is enabled for the user. + bool IsEnabledForUser(User user); + /// /// Gets the internal channels. /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs new file mode 100644 index 0000000000..0846013c45 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs @@ -0,0 +1,10 @@ +using System; + +namespace Jellyfin.Database.Implementations; + +/// +/// An item's id paired with whether a given user has played it. +/// +/// The id of the item. +/// Whether the user has played the item. +public readonly record struct LeafPlayedState(Guid Id, bool Played); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 2edf7681db..93f9083178 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -1229,6 +1229,14 @@ namespace Jellyfin.LiveTv .Where(IsLiveTvEnabled); } + /// + public bool IsEnabledForUser(User user) + { + ArgumentNullException.ThrowIfNull(user); + + return IsLiveTvEnabled(user); + } + /// /// Resets the tuner. /// diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs index 0958512b1a..a9548a6d13 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs @@ -32,6 +32,8 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid(); private readonly Guid _unplayedSeries = Guid.NewGuid(); + private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid(); + private readonly Guid _partiallyPlayedSeries = Guid.NewGuid(); public BaseItemRepositoryPlayedVersionTests() { @@ -68,8 +70,29 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture [Fact] public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() { - Assert.Equal(new HashSet { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true)); - Assert.Equal(new HashSet { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false)); + Assert.Equal( + new HashSet { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions }, + Ids(BaseItemKind.Series, isPlayed: true)); + Assert.Equal( + new HashSet { _unplayedSeries, _partiallyPlayedSeries }, + Ids(BaseItemKind.Series, isPlayed: false)); + } + + [Fact] + public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() + { + Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true)); + Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true)); + } + + [Fact] + public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion() + { + var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true }); + + // Nothing is left to watch, so the series is not half finished. + Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable); + Assert.Contains(_partiallyPlayedSeries, resumable); } private HashSet Ids(BaseItemKind kind, bool isPlayed) @@ -95,6 +118,9 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true); AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false); + AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true); + AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false); + context.SaveChanges(); } @@ -113,7 +139,33 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture { var episodeId = Guid.NewGuid(); - context.BaseItems.Add(new BaseItemEntity + AddSeriesFolder(context, seriesId, name); + + AddItem(context, episodeId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + + AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate); + } + + // A watched first episode plus a second one that is either watched as its alternate version or not + // watched at all, which is what separates a finished series from a half watched one. + private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate) + { + AddSeriesFolder(context, seriesId, name); + + var firstId = Guid.NewGuid(); + AddItem(context, firstId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddPlayedUserData(context, firstId); + + var secondId = Guid.NewGuid(); + AddItem(context, secondId, EpisodeType, $"{name} 2"); + context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate); + } + + private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name) + => context.BaseItems.Add(new BaseItemEntity { Id = seriesId, Type = SeriesType, @@ -123,12 +175,6 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture IsFolder = true }); - AddItem(context, episodeId, EpisodeType, $"{name} 1"); - context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); - - AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate); - } - private void AddItem(JellyfinDbContext context, Guid id, string type, string name) => context.BaseItems.Add(new BaseItemEntity { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs new file mode 100644 index 0000000000..8ed3c61a59 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one +/// whose alternate carries the played row, so an episode already seen must not be offered again. +/// +public sealed class NextUpServiceTests : SqliteDbTestFixture +{ + private const string SeriesKey = "next-up-series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private readonly NextUpService _service; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + private readonly Guid _playedViaAlternate = Guid.NewGuid(); + private readonly Guid _unplayed = Guid.NewGuid(); + + public NextUpServiceTests() + { + var itemTypeLookup = new ItemTypeLookup(); + + using (var context = CreateDbContext()) + { + Seed(context); + } + + _service = new NextUpService( + CreateDbContextFactory(), + itemTypeLookup, + CreateBaseItemRepository(itemTypeLookup)); + } + + [Fact] + public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt() + { + var batch = _service.GetNextUpEpisodesBatch( + new InternalItemsQuery(_user), + [SeriesKey], + includeSpecials: false, + includeWatchedForRewatching: false)[SeriesKey]; + + Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id); + Assert.Equal(_unplayed, batch.NextUp?.Id); + } + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + AddEpisode(context, _playedViaAlternate, 1); + AddEpisode(context, _unplayed, 2); + + // The second file of the first episode, and the only row the playback was recorded against. + // It presents under its primary's key, which is what keeps it out of the candidate list. + var alternateId = Guid.NewGuid(); + context.BaseItems.Add(new BaseItemEntity + { + Id = alternateId, + Type = EpisodeType, + Name = "Episode 1 4K", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = 1, + PresentationUniqueKey = _playedViaAlternate.ToString("N"), + PrimaryVersionId = _playedViaAlternate + }); + + context.SaveChanges(); + + // The link the scanner writes alongside PrimaryVersionId, and the hop the played state + // reaches the alternate through. + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _playedViaAlternate, + ChildId = alternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + context.UserData.Add(new UserData + { + ItemId = alternateId, + UserId = _user.Id, + CustomDataKey = alternateId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); + + context.SaveChanges(); + } + + private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = EpisodeType, + Name = $"Episode {indexNumber}", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = indexNumber, + PresentationUniqueKey = id.ToString("N") + }); +} -- cgit v1.2.3 From 28be079779d4508b5ab2a2fd4b482f3fd929ced5 Mon Sep 17 00:00:00 2001 From: orut34iop Date: Tue, 15 Sep 2026 11:16:56 -0400 Subject: Backport pull request #18006 from jellyfin/release-12.z Preserve active scans when requesting a background library refresh Original-merge: 949e0585a65a91f90644a2475c1e1009e5d51e90 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 19 ++++---- .../Controllers/LibraryStructureController.cs | 6 +-- .../Recordings/RecordingsManager.cs | 2 +- .../Library/LibraryManagerScanTests.cs | 55 ++++++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f97cc7a0a0..2b3ccd5c99 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -318,7 +318,7 @@ namespace Emby.Server.Implementations.Library if (wizardChanged) { - _taskManager.CancelIfRunningAndQueue(); + QueueLibraryScan(); } } @@ -3799,7 +3799,7 @@ namespace Emby.Server.Implementations.Library if (refreshLibrary) { - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3867,13 +3867,16 @@ namespace Emby.Server.Implementations.Library } } - private void StartScanInBackground() + internal Task StartScanInBackground() { - Task.Run(() => + // An active scan already handles library structure changes, so this request can be dropped. + if (IsScanRunning) { - // No need to start if scanning the library because it will handle it - ValidateMediaLibrary(new Progress(), CancellationToken.None); - }); + return Task.CompletedTask; + } + + // Queue instead of restarting so a scan that starts after the check is allowed to finish. + return Task.Run(QueueLibraryScan); } public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath) @@ -3984,7 +3987,7 @@ namespace Emby.Server.Implementations.Library { await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - StartScanInBackground(); + _ = StartScanInBackground(); } else { diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 65bfe25d21..7f63e410a5 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -213,7 +213,7 @@ public class LibraryStructureController : BaseJellyfinApiController { _libraryManager.ClearIgnoreRuleCache(); // We don't know if this one can be validated individually, trigger a new validation - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } _libraryManager.ClearIgnoreRuleCache(); @@ -260,7 +260,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { @@ -327,7 +327,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs index 62a06370da..8a5aeccba5 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -286,7 +286,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs new file mode 100644 index 0000000000..6d0c491382 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.ScheduledTasks.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Tasks; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class LibraryManagerScanTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartScanInBackground_QueuesOnlyWhenIdle(bool scanRunning) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze>(); + var manager = fixture.Create(); + typeof(ServerLibraryManager).GetProperty(nameof(ServerLibraryManager.IsScanRunning))!.SetValue(manager, scanRunning); + + await manager.StartScanInBackground().ConfigureAwait(true); + + tasks.Verify(t => t.QueueScheduledTask(), scanRunning ? Times.Never() : Times.Once()); + tasks.Verify(t => t.CancelIfRunningAndQueue(), Times.Never()); + } + + [Fact] + public async Task ValidateMediaLibrary_RestartsScheduledScan() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze>(); + var manager = fixture.Create(); + + await manager.ValidateMediaLibrary(new Progress(), CancellationToken.None).ConfigureAwait(true); + + tasks.Verify(t => t.CancelIfRunningAndQueue(), Times.Once()); + tasks.Verify(t => t.QueueScheduledTask(), Times.Never()); + } +} -- cgit v1.2.3 From cdd2b3e0479343aaf33d21e6356412377b534b2c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:17:07 -0400 Subject: Backport pull request #18030 from jellyfin/release-12.z Stop wrong-type alternate version cleanup from recursing Original-merge: af368af0619ab04594169468790a6b61f55543b1 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 13 +- .../LibraryManager/ResolveAlternateVersionTests.cs | 145 +++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2b3ccd5c99..caba304888 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -878,7 +878,18 @@ namespace Emby.Server.Implementations.Library wrongTypeItem.GetType().Name, expectedVideoType.Name, path); - DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false }); + + // A full DeleteItem would save the primary version, which resolves its + // alternates again and re-enters here before this row is gone. + DeleteItemsUnsafeFast([wrongTypeItem]); + + // The fast path skips the parent bookkeeping, and the stale item is listed + // under its ParentId, so that folder's cached listing has to be dropped. + if (wrongTypeItem.GetParent() is Folder staleParent) + { + staleParent.Children = null; + staleParent.UserData = null; + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs new file mode 100644 index 0000000000..31109b2968 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +public sealed class ResolveAlternateVersionTests : IDisposable +{ + private const string PrimaryPath = "/movies/Up/Up.mkv"; + private const string AlternatePath = "/movies/Up/Up - 1080p.mkv"; + + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly Mock _persistenceServiceMock; + private readonly Folder _staleParent; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IMediaSourceManager? _previousMediaSourceManager; + private readonly IItemRepository? _previousItemRepository; + + public ResolveAlternateVersionTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + _persistenceServiceMock = fixture.Freeze>(); + var itemRepositoryMock = fixture.Freeze>(); + fixture.Freeze>() + .Setup(f => f.GetFileInfo(It.IsAny())) + .Returns(path => new FileSystemMetadata { FullName = path }); + + _libraryManager = fixture.Build() + .Do(s => s.AddParts( + fixture.Create>(), + [], + fixture.Create>(), + fixture.Create>(), + fixture.Create>())) + .Create(); + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousMediaSourceManager = BaseItem.MediaSourceManager; + _previousItemRepository = BaseItem.ItemRepository; + BaseItem.LibraryManager = _libraryManager; + + var mediaSourceManagerMock = new Mock(); + mediaSourceManagerMock.Setup(m => m.GetMediaStreams(It.IsAny())).Returns([]); + mediaSourceManagerMock.Setup(m => m.GetMediaAttachments(It.IsAny())).Returns([]); + BaseItem.MediaSourceManager = mediaSourceManagerMock.Object; + + // A reloaded listing comes back empty, so a stale entry surviving is visible. + itemRepositoryMock.Setup(i => i.GetItemList(It.IsAny())).Returns([]); + BaseItem.ItemRepository = itemRepositoryMock.Object; + + BaseItem.FileSystem ??= fixture.Create(); + BaseItem.MediaSegmentManager ??= fixture.Create(); + BaseItem.ConfigurationManager ??= fixture.Create(); + Video.RecordingsManager ??= fixture.Create(); + + var primary = new Movie + { + Name = "Up", + Path = PrimaryPath, + LocalAlternateVersions = [AlternatePath], + Id = _libraryManager.GetNewItemId(PrimaryPath, typeof(Movie)) + }; + + _staleParent = new Folder + { + Name = "Up", + Path = "/movies/Up", + Id = _libraryManager.GetNewItemId("/movies/Up", typeof(Folder)) + }; + + var staleAlternate = new Video + { + Name = "Up - 1080p", + Path = AlternatePath, + OwnerId = primary.Id, + ParentId = _staleParent.Id, + Id = _libraryManager.GetNewItemId(AlternatePath, typeof(Video)) + }; + staleAlternate.SetPrimaryVersionId(primary.Id); + + itemRepositoryMock + .Setup(i => i.RetrieveItem(It.IsAny())) + .Returns(id => id.Equals(primary.Id) ? primary + : id.Equals(staleAlternate.Id) ? staleAlternate + : id.Equals(_staleParent.Id) ? _staleParent + : null!); + + StaleAlternateId = staleAlternate.Id; + } + + private Guid StaleAlternateId { get; } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.MediaSourceManager = _previousMediaSourceManager!; + BaseItem.ItemRepository = _previousItemRepository!; + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsRowWithoutResavingPrimary() + { + // The alternate is stored under the id of the generic Video type while its primary is a Movie. + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + _persistenceServiceMock.Verify( + p => p.DeleteItem(It.Is>(ids => ids.Count == 1 && ids[0].Equals(StaleAlternateId))), + Times.Once); + + // Saving the primary is what re-enters this method before the stale row is gone. + _persistenceServiceMock.Verify( + p => p.SaveItems(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsCachedParentListing() + { + _staleParent.Children = [new Video { Name = "Up - 1080p", Path = AlternatePath }]; + + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + Assert.Empty(_staleParent.Children); + } +} -- cgit v1.2.3 From 2151b992f96f6f0ba9d404977d536741a281584c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:17:09 -0400 Subject: Backport pull request #18039 from jellyfin/release-12.z Stop resolving items with every field where only stored columns are read Original-merge: 408e5e01b1447e24689bd3653e2328bf173c8ce3 Merged-by: crobibero Backported-by: Cody Robibero --- .../Collections/CollectionManager.cs | 3 +- .../Library/UserViewManager.cs | 48 +++++++++++++--------- .../Playlists/PlaylistManager.cs | 10 ++--- MediaBrowser.Controller/Dto/DtoOptions.cs | 14 +++++++ MediaBrowser.Controller/Entities/Folder.cs | 18 ++++++-- MediaBrowser.Controller/Entities/Movies/BoxSet.cs | 15 +++---- MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs | 4 +- 7 files changed, 76 insertions(+), 36 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 9567a29a98..cd93f76d26 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -9,6 +9,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; @@ -235,7 +236,7 @@ namespace Emby.Server.Implementations.Collections List? itemList = null; - var linkedChildrenList = collection.GetLinkedChildren(); + var linkedChildrenList = collection.GetLinkedChildren(DtoOptions.StoredColumnsOnly); var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList(); foreach (var id in ids) diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index db911cd24a..cfb2dd53d3 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,26 +60,10 @@ namespace Emby.Server.Implementations.Library var folderViewType = collectionFolder?.CollectionType; // Playlist and BoxSet libraries require special handling because the folder only references linked items - if (folderViewType == CollectionType.boxsets) + if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) + && !HasVisibleChild(folder, user)) { - // Only the existence of one visible box set matters here, so probe the children - // lazily and stop at the first hit. - if (!folder.Children.Any(item => item.IsVisible(user))) - { - continue; - } - } - else if (folderViewType == CollectionType.playlists) - { - var items = folder.GetItemList(new InternalItemsQuery(user) - { - ParentId = folder.ParentId - }); - - if (!items.Any(item => item.IsVisible(user))) - { - continue; - } + continue; } if (UserView.IsUserSpecific(folder)) @@ -168,6 +152,32 @@ namespace Emby.Server.Implementations.Library .ToArray(); } + private bool HasVisibleChild(Folder folder, User user) + { + // Folder.Children answers this too, but a collection folder delegates it to its physical + // folders, which resolve and then hold on to every child with every field. + var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 + ? collectionFolder.PhysicalFolderIds + : [folder.Id]; + + foreach (var parentId in parentIds) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + ParentId = parentId, + GroupByPresentationUniqueKey = false, + DtoOptions = DtoOptions.StoredColumnsOnly + }); + + if (items.Any(item => item.IsVisible(user))) + { + return true; + } + } + + return false; + } + public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName) { var uniqueId = parentId + "subview" + type; diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 308faed8cc..8208c85222 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -371,7 +371,7 @@ namespace Emby.Server.Implementations.Playlists if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new WplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new WplPlaylistEntry() { @@ -404,7 +404,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new ZplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new ZplPlaylistEntry() { @@ -440,7 +440,7 @@ namespace Emby.Server.Implementations.Playlists { IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -472,7 +472,7 @@ namespace Emby.Server.Implementations.Playlists IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -500,7 +500,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase)) { var playlist = new PlsPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new PlsPlaylistEntry() { diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index 052626355f..4259b7b65c 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -46,6 +46,20 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + /// + /// Gets options that populate nothing beyond the item's own stored columns. + /// + /// + /// Each enabled field group is a collection the item query left-joins, so the rows returned are + /// the product of the item's provider, image and user data counts. Never use this for items that + /// get saved back: saving rewrites the owned rows from what the instance holds. + /// + public static DtoOptions StoredColumnsOnly => new(false) + { + EnableImages = false, + EnableUserData = false + }; + /// /// Gets or sets the fields to populate on the DTO. /// diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index dae4612aa1..626bc0d5a1 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1687,7 +1687,17 @@ namespace MediaBrowser.Controller.Entities /// IEnumerable{BaseItem}. public List GetLinkedChildren() { - var resolved = ResolveLinkedChildren(LinkedChildren); + return GetLinkedChildren(new DtoOptions()); + } + + /// + /// Gets the linked children, populating only what asks for. + /// + /// Fields to populate on the resolved children. + /// The resolved children. + public List GetLinkedChildren(DtoOptions options) + { + var resolved = ResolveLinkedChildren(LinkedChildren, options); var list = new List(resolved.Count); foreach (var (_, item) in resolved) { @@ -1804,8 +1814,9 @@ namespace MediaBrowser.Controller.Entities /// path (legacy path-based resolution). /// /// Linked children to resolve. + /// Fields to populate on the resolved items; all fields when null. /// Each input entry paired with its resolved item; entries that fail to resolve are dropped. - private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList linkedChildren) + private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList linkedChildren, DtoOptions options = null) { var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count); if (linkedChildren.Count == 0) @@ -1827,7 +1838,8 @@ namespace MediaBrowser.Controller.Entities { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToBatch] + ItemIds = [.. idsToBatch], + DtoOptions = options ?? new DtoOptions() }); byId = new Dictionary(batched.Count); foreach (var item in batched) diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index d7f9102be9..16d8bfe391 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -11,6 +11,7 @@ using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -89,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies return base.GetNonCachedChildren(directoryService); } - return Enumerable.Empty(); + return []; } protected override IReadOnlyList LoadChildren() @@ -172,7 +173,7 @@ namespace MediaBrowser.Controller.Entities.Movies var libraryFolderIds = LibraryFolderIds; if (libraryFolderIds is null) { - linkedItems = GetLinkedChildren(); + linkedItems = GetLinkedChildren(DtoOptions.StoredColumnsOnly); libraryFolderIds = GetLibraryFolderIds(linkedItems); } @@ -191,7 +192,7 @@ namespace MediaBrowser.Controller.Entities.Movies // If user has parental controls, hide the BoxSet when all children are restricted if (user.MaxParentalRatingScore.HasValue) { - linkedItems ??= GetLinkedChildren(); + linkedItems ??= GetLinkedChildren(DtoOptions.StoredColumnsOnly); if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { return false; @@ -248,7 +249,7 @@ namespace MediaBrowser.Controller.Entities.Movies public Guid[] GetLibraryFolderIds() { - return GetLibraryFolderIds(GetLinkedChildren()); + return GetLibraryFolderIds(GetLinkedChildren(DtoOptions.StoredColumnsOnly)); } private Guid[] GetLibraryFolderIds(IEnumerable linkedChildren) @@ -280,13 +281,13 @@ namespace MediaBrowser.Controller.Entities.Movies { expandedFolders.Add(item.Id); - return FlattenItems(boxset.GetLinkedChildren(), expandedFolders); + return FlattenItems(boxset.GetLinkedChildren(DtoOptions.StoredColumnsOnly), expandedFolders); } - return Array.Empty(); + return []; } - return new[] { item }; + return [item]; } } } diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index bc184d82fb..d5e233d4f8 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Xml; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; @@ -500,7 +501,8 @@ namespace MediaBrowser.LocalMetadata.Savers { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToResolve] + ItemIds = [.. idsToResolve], + DtoOptions = DtoOptions.StoredColumnsOnly }); pathById = new Dictionary(batched.Count); foreach (var batchedItem in batched) -- cgit v1.2.3 From 64befbcdaa2718468fc015cc8224c8d71ddd001c Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 27 Sep 2026 16:30:32 -0400 Subject: Backport pull request #18005 from jellyfin/release-12.z Match artist creation lookups using CleanName Original-merge: d341176eef63e38839f648b00a358240279ec363 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 1 - .../Item/BaseItemRepository.TranslateQuery.cs | 12 +- .../Entities/InternalItemsQuery.cs | 2 - .../Item/ArtistNameLookupTests.cs | 173 +++++++++++++++++++++ 4 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index caba304888..4c468569bc 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1354,7 +1354,6 @@ namespace Emby.Server.Implementations.Library { IncludeItemTypes = [BaseItemKind.MusicArtist], Name = name, - UseRawName = true, DtoOptions = options }).Cast() .OrderBy(i => i.IsAccessedByName ? 1 : 0) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index d726f0f143..a7110485b4 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -476,16 +476,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Name)) { - if (filter.UseRawName == true) - { - var nameLower = filter.Name.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower); - } - else - { - var cleanName = filter.Name.GetCleanValue(); - baseQuery = baseQuery.Where(e => e.CleanName == cleanName); - } + var cleanName = filter.Name.GetCleanValue(); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); } var nameContains = filter.NameContains; diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 7c88d5dd05..e0f0e0d476 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -235,8 +235,6 @@ namespace MediaBrowser.Controller.Entities public string? Name { get; set; } - public bool? UseRawName { get; set; } - public string? Person { get; set; } public Guid[] PersonIds { get; set; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs new file mode 100644 index 0000000000..0c1d453293 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ArtistNameLookupTests : SqliteDbTestFixture +{ + private readonly CommandRecorder _recorder; + + public ArtistNameLookupTests() + : this(new CommandRecorder()) + { + } + + private ArtistNameLookupTests(CommandRecorder recorder) + : base(recorder) + { + _recorder = recorder; + } + + [Theory] + [InlineData("Björk", "bjork")] + [InlineData("AC/DC", "ac dc")] + [InlineData("An Artist", " AN ARTIST ")] + [InlineData("Artist", "ARTIST")] + public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName) + { + var lookup = new ItemTypeLookup(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = artistId, + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = storedName, + CleanName = storedName.GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.Person] + }); + context.SaveChanges(); + } + + var manager = CreateLibraryManager(lookup); + _recorder.Commands.Clear(); + + Assert.Equal(artistId, manager.GetArtist(requestedName).Id); + var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal)); + Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal)); + Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id); + } + + [Fact] + public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch() + { + var lookup = new ItemTypeLookup(); + var parentId = Guid.NewGuid(); + var artistId = Guid.NewGuid(); + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + new BaseItemEntity + { + Id = parentId, + Name = "Music", + Type = lookup.BaseItemKindNames[BaseItemKind.Folder] + }, + new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = "Bjork", + CleanName = "Bjork".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }, + new BaseItemEntity + { + Id = artistId, + ParentId = parentId, + Name = "Björk", + CleanName = "Björk".GetCleanValue(), + Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist] + }); + context.SaveChanges(); + } + + Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id); + } + + private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup) + { + var repository = CreateBaseItemRepository(lookup); + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + fixture.Inject(repository); + fixture.Inject(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository)); + return fixture.Create(); + } + + private string[] Explain(RecordedCommand query) + { + using var context = CreateDbContext(); + using var command = context.Database.GetDbConnection().CreateCommand(); +#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters. + command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql; +#pragma warning restore CA2100 + foreach (var value in query.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = value.Name; + parameter.Value = value.Value; + command.Parameters.Add(parameter); + } + + using var reader = command.ExecuteReader(); + var plan = new List(); + while (reader.Read()) + { + plan.Add(reader.GetString(3)); + } + + return plan.ToArray(); + } + + private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters); + + private sealed class CommandRecorder : DbCommandInterceptor + { + public List Commands { get; } = []; + + public override InterceptionResult ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Record(command); + return result; + } + + public override InterceptionResult NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Record(command); + return result; + } + + private void Record(DbCommand command) => Commands.Add(new RecordedCommand( + command.CommandText, + command.Parameters.Cast().Select(p => (p.ParameterName, p.Value)).ToArray())); + } +} -- cgit v1.2.3 From 7693488122ee612faa15a02891ae85f792d36374 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 27 Sep 2026 16:30:44 -0400 Subject: Backport pull request #18104 from jellyfin/release-12.z Fix playlist views Original-merge: 074cc5b6d7914064e8cd3447b17c5375715fcfac Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/UserViewManager.cs | 50 ++++++++++++---------- 1 file changed, 28 insertions(+), 22 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index cfb2dd53d3..6d52ce7440 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,10 +60,16 @@ namespace Emby.Server.Implementations.Library var folderViewType = collectionFolder?.CollectionType; // Playlist and BoxSet libraries require special handling because the folder only references linked items - if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) - && !HasVisibleChild(folder, user)) + if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) { - continue; + var itemKind = folderViewType == CollectionType.playlists + ? BaseItemKind.Playlist + : BaseItemKind.BoxSet; + + if (!HasVisibleItem(itemKind, folders, user)) + { + continue; + } } if (UserView.IsUserSpecific(folder)) @@ -152,30 +158,30 @@ namespace Emby.Server.Implementations.Library .ToArray(); } - private bool HasVisibleChild(Folder folder, User user) + private bool HasVisibleItem(BaseItemKind itemKind, IReadOnlyList folders, User user) { - // Folder.Children answers this too, but a collection folder delegates it to its physical - // folders, which resolve and then hold on to every child with every field. - var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 - ? collectionFolder.PhysicalFolderIds - : [folder.Id]; + var topParentIds = folders.SelectMany(GetTopParentIds).ToArray(); + if (topParentIds.Length == 0) + { + return false; + } - foreach (var parentId in parentIds) + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - ParentId = parentId, - GroupByPresentationUniqueKey = false, - DtoOptions = DtoOptions.StoredColumnsOnly - }); + IncludeItemTypes = [itemKind], + TopParentIds = topParentIds, + GroupByPresentationUniqueKey = false, + DtoOptions = DtoOptions.StoredColumnsOnly + }); - if (items.Any(item => item.IsVisible(user))) - { - return true; - } - } + return items.Any(item => item.IsVisible(user)); + } - return false; + private static IEnumerable GetTopParentIds(Folder folder) + { + return folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 + ? collectionFolder.PhysicalFolderIds + : [folder.Id]; } public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName) -- cgit v1.2.3 From 0d1483e2ddb43faf7904ef09fb022a4100397260 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 27 Sep 2026 16:30:50 -0400 Subject: Backport pull request #18139 from jellyfin/release-12.z Only regenerate extra names that were generated by the scan Original-merge: 25d97ac5d0c7b71a8d103b6a23fc2b487d4a2a47 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 49 ++++++++++++++-- .../Library/LibraryManager/FindExtrasTests.cs | 68 +++++++++++++++++++++- 2 files changed, 110 insertions(+), 7 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 4c468569bc..6f3df084c0 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3466,6 +3466,7 @@ namespace Emby.Server.Implementations.Library var extras = new List(); var typeCounters = new Dictionary(); + var generatedNames = new Dictionary>(); // Order by path so that the numbering handed out below does not depend on the // order the file system happened to list the folder in @@ -3502,10 +3503,12 @@ namespace Emby.Server.Implementations.Library extra = itemById; } - // An extra is named after its file, so the file is the source of truth. Items created - // by older versions, or renamed by a metadata provider, are corrected here; - // RefreshExtras persists the change. - if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true) + // The name derived from the file is only a default. A name that came from anywhere else, + // such as a local metadata file, is the user's and has to survive the scan, so only a + // name this method handed out itself is renewed; RefreshExtras persists the change. + if (!string.IsNullOrEmpty(name) + && extra.LockedFields?.Contains(MetadataField.Name) != true + && (itemById is null || IsGeneratedExtraName(extra.Name, candidate))) { extra.Name = name; } @@ -3527,6 +3530,31 @@ namespace Emby.Server.Implementations.Library return null; } + + bool IsGeneratedExtraName(string currentName, ExtraCandidate candidate) + { + // The file name is what an extra was called before it was given a name of its type + if (string.Equals(currentName, candidate.Extra.Name, StringComparison.Ordinal)) + { + return true; + } + + if (!generatedNames.TryGetValue(candidate.ExtraType, out var names)) + { + // Any of the numbers of this type may have been handed out, as the order the extras + // of a type are numbered in shifts as files appear beside them or are taken away + names = new HashSet(StringComparer.Ordinal); + var count = candidates.Count(c => c.ExtraType == candidate.ExtraType); + for (var seen = 0; seen < count; seen++) + { + names.Add(GetNumberedExtraName(candidate.ExtraType, seen)); + } + + generatedNames[candidate.ExtraType] = names; + } + + return names.Contains(currentName); + } } /// @@ -3553,7 +3581,18 @@ namespace Emby.Server.Implementations.Library typeCounters.TryGetValue(candidate.ExtraType, out var seen); typeCounters[candidate.ExtraType] = seen + 1; - var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + return GetNumberedExtraName(candidate.ExtraType, seen); + } + + /// + /// Gets the name given to the n-th extra of a type that is named after its type. + /// + /// The extra type. + /// Number of extras of the type named before this one. + /// The name. + private string GetNumberedExtraName(ExtraType extraType, int seen) + { + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(extraType)); return seen == 0 ? typeName diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index a28c1d6dfb..f717d9b665 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -29,6 +29,7 @@ public class FindExtrasTests { private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; private readonly Mock _fileSystemMock; + private readonly Mock _itemRepositoryMock; public FindExtrasTests() { @@ -36,8 +37,8 @@ public class FindExtrasTests fixture.Register(() => new NamingOptions()); var configMock = fixture.Freeze>(); configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); - var itemRepository = fixture.Freeze>(); - itemRepository.Setup(i => i.RetrieveItem(It.IsAny())).Returns(null); + _itemRepositoryMock = fixture.Freeze>(); + _itemRepositoryMock.Setup(i => i.RetrieveItem(It.IsAny())).Returns(null); _fileSystemMock = fixture.Freeze>(); _fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny())).Returns(path => new FileSystemMetadata { FullName = path }); @@ -481,6 +482,69 @@ public class FindExtrasTests Assert.Equal("Trailer 4", extras[3].Name); } + [Fact] + public void FindExtras_ExtraNamedByLocalMetadata_KeepsItsNameOnRescan() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + + // A local metadata file gives the extra a title of its own, which the refresh persists + extra.Name = "Cannes Teaser"; + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + var rescanned = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Cannes Teaser", rescanned.Name); + } + + [Fact] + public void FindExtras_ExtraKeptItsGeneratedName_IsRenumberedOnRescan() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var directoryService = new DirectoryService(_fileSystemMock.Object); + var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService)); + Assert.Equal("Trailer", extra.Name); + _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra); + + // A trailer sorting before the known one takes the first number, so the known one moves on + files.Add(new FileSystemMetadata + { + FullName = "/movies/Up (2009)/Up (2009)-trailer1.mkv", + IsDirectory = false + }); + + var rescanned = _libraryManager.FindExtras(owner, files, directoryService) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal("Trailer", rescanned["/movies/Up (2009)/Up (2009)-trailer1.mkv"]); + Assert.Equal("Trailer 2", rescanned["/movies/Up (2009)/Up (2009)-trailer2.mkv"]); + } + [Fact] public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() { -- cgit v1.2.3 From e7764cbbd9db5e20ce764121d550b732405100e3 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 5 Oct 2026 19:18:41 -0400 Subject: Backport pull request #18001 from jellyfin/release-12.z Keep an item's user data rows in agreement Original-merge: fc98019b12e311fea244dccef72d521278326024 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/UserDataManager.cs | 16 +++ .../Item/ItemPersistenceService.cs | 73 ++++++++-- .../20260912120000_HarmonizeConflictingUserData.cs | 131 +++++++++++++++++ .../Item/ItemPersistenceOwnedRowTests.cs | 159 +++++++++++++++++++++ .../Library/UserDataManagerTests.cs | 40 ++++++ .../HarmonizeConflictingUserDataTests.cs | 138 ++++++++++++++++++ 6 files changed, 549 insertions(+), 8 deletions(-) create mode 100644 Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs create mode 100644 tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 0680046c11..1e7068b24b 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -75,6 +75,22 @@ namespace Emby.Server.Implementations.Library } dbContext.SaveChanges(); + + // Rows under keys the item no longer reports are kept, not deleted: the key set is briefly + // incomplete mid-refresh, and those rows are what a deleted and re-added item reattaches by. + dbContext.UserData + .Where(e => e.ItemId == item.Id && e.UserId == user.Id && !keys.Contains(e.CustomDataKey)) + .ExecuteUpdate(e => e + .SetProperty(f => f.AudioStreamIndex, userData.AudioStreamIndex) + .SetProperty(f => f.IsFavorite, userData.IsFavorite) + .SetProperty(f => f.LastPlayedDate, userData.LastPlayedDate) + .SetProperty(f => f.Likes, userData.Likes) + .SetProperty(f => f.PlaybackPositionTicks, userData.PlaybackPositionTicks) + .SetProperty(f => f.PlayCount, userData.PlayCount) + .SetProperty(f => f.Played, userData.Played) + .SetProperty(f => f.Rating, userData.Rating) + .SetProperty(f => f.SubtitleStreamIndex, userData.SubtitleStreamIndex)); + transaction.Commit(); var userId = user.InternalId; diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index 8b79e44680..3bf33b969e 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -230,17 +230,18 @@ public class ItemPersistenceService : IItemPersistenceService var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); await using (transaction.ConfigureAwait(false)) { - var userKeys = item.GetUserDataKeys().ToArray(); - var retentionDate = (DateTime?)null; + var userKeys = item.GetUserDataKeys().Distinct().ToList(); - await dbContext.UserData + var detached = await dbContext.UserData .Where(e => e.ItemId == BaseItemRepository.PlaceholderId) .Where(e => userKeys.Contains(e.CustomDataKey)) - .ExecuteUpdateAsync( - e => e - .SetProperty(f => f.ItemId, item.Id) - .SetProperty(f => f.RetentionDate, retentionDate), - cancellationToken).ConfigureAwait(false); + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (detached.Count > 0) + { + await ReconcileUserDataAsync(dbContext, item, userKeys, detached, cancellationToken).ConfigureAwait(false); + } item.UserData = await dbContext.UserData .AsNoTracking() @@ -253,6 +254,62 @@ public class ItemPersistenceService : IItemPersistenceService } } + private static async Task ReconcileUserDataAsync( + JellyfinDbContext dbContext, + BaseItemDto item, + IReadOnlyList userKeys, + List detached, + CancellationToken cancellationToken) + { + var existing = await dbContext.UserData + .Where(e => e.ItemId == item.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + // Keys the item no longer reports are carried over: they may only be missing mid-refresh. + var winners = detached.Concat(existing) + .GroupBy(e => e.UserId) + .Select(g => ( + Winner: g + .OrderByDescending(e => e.LastPlayedDate) + .ThenByDescending(e => e.PlayCount) + .ThenByDescending(e => e.PlaybackPositionTicks) + .First(), + Keys: userKeys.Union(g.Select(e => e.CustomDataKey)).ToList())) + .ToList(); + + dbContext.UserData.RemoveRange(detached); + dbContext.UserData.RemoveRange(existing); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + foreach (var (winner, keys) in winners) + { + foreach (var key in keys) + { + dbContext.UserData.Add(new UserData + { + ItemId = item.Id, + Item = null, + UserId = winner.UserId, + User = null, + CustomDataKey = key, + RetentionDate = null, + AudioStreamIndex = winner.AudioStreamIndex, + IsFavorite = winner.IsFavorite, + LastPlayedDate = winner.LastPlayedDate, + Likes = winner.Likes, + PlaybackPositionTicks = winner.PlaybackPositionTicks, + PlayCount = winner.PlayCount, + Played = winner.Played, + Rating = winner.Rating, + SubtitleStreamIndex = winner.SubtitleStreamIndex + }); + } + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + private void UpdateOrInsertItems(IReadOnlyList items, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(items); diff --git a/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs b/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs new file mode 100644 index 0000000000..2824c3234f --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Collapses conflicting user data rows so every row an item holds for a user says the same thing. +/// +[JellyfinMigration("2026-09-12T12:00:00", nameof(HarmonizeConflictingUserData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class HarmonizeConflictingUserData : IAsyncMigrationRoutine +{ + private const int BatchSize = 500; + + private readonly IStartupLogger _logger; + private readonly IDbContextFactory _dbContextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The database context factory. + public HarmonizeConflictingUserData( + IStartupLogger logger, + IDbContextFactory dbContextFactory) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var conflicts = await context.UserData + .Where(e => !e.ItemId.Equals(BaseItemRepository.PlaceholderId)) + .GroupBy(e => new { e.ItemId, e.UserId }) + .Where(g => g.Count() > 1 + && (g.Min(e => e.PlaybackPositionTicks) != g.Max(e => e.PlaybackPositionTicks) + || g.Min(e => e.PlayCount) != g.Max(e => e.PlayCount) + || g.Min(e => e.Played ? 1 : 0) != g.Max(e => e.Played ? 1 : 0) + || g.Min(e => e.IsFavorite ? 1 : 0) != g.Max(e => e.IsFavorite ? 1 : 0) + || g.Min(e => e.LastPlayedDate) != g.Max(e => e.LastPlayedDate))) + .Select(g => new { g.Key.ItemId, g.Key.UserId }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (conflicts.Count == 0) + { + _logger.LogInformation("No conflicting user data found."); + return; + } + + var conflictKeys = conflicts.ConvertAll(e => new ConflictKey(e.ItemId, e.UserId)); + + _logger.LogInformation("Harmonizing user data for {Count} item/user combinations.", conflictKeys.Count); + + var harmonized = 0; + foreach (var batch in conflictKeys.Chunk(BatchSize)) + { + harmonized += await HarmonizeBatchAsync(context, batch, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation("Updated {Count} user data rows.", harmonized); + } + } + + private static async Task HarmonizeBatchAsync(JellyfinDbContext context, ConflictKey[] batch, CancellationToken cancellationToken) + { + var itemIds = batch.Select(e => e.ItemId).Distinct().ToArray(); + var wanted = batch.ToHashSet(); + + var rows = await context.UserData + .WhereOneOrMany(itemIds, e => e.ItemId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var updated = 0; + foreach (var group in rows.GroupBy(e => new ConflictKey(e.ItemId, e.UserId))) + { + if (!wanted.Contains(group.Key)) + { + continue; + } + + // The most recent play is the state the user last produced; the others are fossils of + // earlier incarnations of the same item. + var winner = group + .OrderByDescending(e => e.LastPlayedDate) + .ThenByDescending(e => e.PlayCount) + .ThenByDescending(e => e.PlaybackPositionTicks) + .First(); + + foreach (var row in group) + { + if (ReferenceEquals(row, winner)) + { + continue; + } + + row.AudioStreamIndex = winner.AudioStreamIndex; + row.IsFavorite = winner.IsFavorite; + row.LastPlayedDate = winner.LastPlayedDate; + row.Likes = winner.Likes; + row.PlaybackPositionTicks = winner.PlaybackPositionTicks; + row.PlayCount = winner.PlayCount; + row.Played = winner.Played; + row.Rating = winner.Rating; + row.SubtitleStreamIndex = winner.SubtitleStreamIndex; + updated++; + } + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + + return updated; + } + + private readonly record struct ConflictKey(Guid ItemId, Guid UserId); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs index 82614c3156..fea6ccb7a1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs @@ -2,12 +2,16 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Server.Implementations.Item; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging.Abstractions; @@ -21,12 +25,15 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture private readonly ItemPersistenceService _service; private readonly ILibraryManager? _previousLibraryManager; private readonly IServerConfigurationManager? _previousConfigurationManager; + private readonly IRecordingsManager? _previousRecordingsManager; + private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111"); public ItemPersistenceOwnedRowTests() { // BaseItem resolves these through process-wide statics; restored in Dispose. _previousLibraryManager = BaseItem.LibraryManager; _previousConfigurationManager = BaseItem.ConfigurationManager; + _previousRecordingsManager = Video.RecordingsManager; var libraryManager = new Mock(); libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny())) @@ -37,6 +44,9 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); BaseItem.ConfigurationManager = configurationManager.Object; + // Video.SourceType consults this before it can produce user data keys. + Video.RecordingsManager = new Mock().Object; + _service = new ItemPersistenceService( CreateDbContextFactory(), new Mock().Object, @@ -47,6 +57,7 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture { BaseItem.LibraryManager = _previousLibraryManager!; BaseItem.ConfigurationManager = _previousConfigurationManager!; + Video.RecordingsManager = _previousRecordingsManager!; base.Dispose(disposing); } @@ -102,6 +113,99 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue); } + [Fact] + public async Task ReattachUserData_DetachedRowsFromDifferentEras_CollapsesToMostRecentPlay() + { + var movie = CreateMovie(Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd")); + var keys = movie.GetUserDataKeys(); + SeedUserDataItem(movie); + + using (var ctx = CreateDbContext()) + { + // The guid-keyed row was detached by an older deletion than the provider-keyed ones. + ctx.UserData.AddRange( + CreateDetachedRow(keys[^1], new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490), + CreateDetachedRow(keys[0], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true), + CreateDetachedRow(keys[1], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true)); + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList(); + + Assert.Equal(keys.Count, rows.Count); + Assert.Equal(keys.OrderBy(e => e, StringComparer.Ordinal), rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal)); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(0, row.PlaybackPositionTicks); + Assert.Equal(9, row.PlayCount); + Assert.Null(row.RetentionDate); + }); + + Assert.Empty(ctx.UserData.Where(e => e.ItemId.Equals(BaseItemRepository.PlaceholderId))); + } + } + + [Fact] + public async Task ReattachUserData_ExistingRowUnderUnreportedKey_IsKeptInAgreement() + { + var movie = CreateMovie(Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff")); + var keys = movie.GetUserDataKeys(); + const string UnreportedKey = "tvdb-key-missing-mid-refresh"; + SeedUserDataItem(movie); + + using (var ctx = CreateDbContext()) + { + ctx.UserData.AddRange( + CreateRow(movie.Id, UnreportedKey, new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123), + CreateDetachedRow(keys[0], new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 0, played: true)); + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList(); + + Assert.Equal( + keys.Append(UnreportedKey).OrderBy(e => e, StringComparer.Ordinal), + rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal)); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(2, row.PlayCount); + Assert.Equal(0, row.PlaybackPositionTicks); + }); + } + } + + [Fact] + public async Task ReattachUserData_NoDetachedRows_LeavesExistingRowsAlone() + { + var movie = CreateMovie(Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")); + var keys = movie.GetUserDataKeys(); + SeedUserDataItem(movie); + + using (var ctx = CreateDbContext()) + { + ctx.UserData.Add(CreateRow(movie.Id, keys[0], new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123)); + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var row = Assert.Single(ctx.UserData.Where(e => e.ItemId.Equals(movie.Id))); + Assert.Equal(123, row.PlaybackPositionTicks); + } + } + private static Book CreateBook(Guid id, Dictionary providerIds, MetadataField[] lockedFields) { var book = new Book @@ -115,4 +219,59 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0); return book; } + + private static Movie CreateMovie(Guid id) + { + return new Movie + { + Id = id, + Name = "Black Widow", + ProviderIds = new Dictionary + { + ["Tmdb"] = "497698", + ["Imdb"] = "tt3480822" + } + }; + } + + private void SeedUserDataItem(BaseItem item) + { + using var ctx = CreateDbContext(); + if (!ctx.Users.Any(e => e.Id.Equals(_userId))) + { + ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + } + + if (!ctx.BaseItems.Any(e => e.Id.Equals(BaseItemRepository.PlaceholderId))) + { + ctx.BaseItems.Add(new BaseItemEntity { Id = BaseItemRepository.PlaceholderId, Type = typeof(Folder).FullName! }); + } + + ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = item.GetType().FullName! }); + ctx.SaveChanges(); + } + + private UserData CreateDetachedRow(string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false) + { + var row = CreateRow(BaseItemRepository.PlaceholderId, key, lastPlayed, playCount, positionTicks, played); + row.RetentionDate = new DateTime(2025, 6, 22, 0, 0, 0, DateTimeKind.Utc); + + return row; + } + + private UserData CreateRow(Guid itemId, string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false) + { + return new UserData + { + ItemId = itemId, + Item = null, + UserId = _userId, + User = null, + CustomDataKey = key, + LastPlayedDate = lastPlayed, + PlayCount = playCount, + PlaybackPositionTicks = positionTicks, + Played = played + }; + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs index ba3127bc08..2ed16de15b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; @@ -8,6 +10,7 @@ using Jellyfin.Database.Providers.Sqlite; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -207,6 +210,43 @@ public sealed class UserDataManagerTests : IDisposable Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks); } + [Fact] + public void SaveUserData_RowUnderRetiredKey_IsKeptInAgreement() + { + var item = CreateAudioBook(); + + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(_user); + ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = typeof(AudioBook).FullName! }); + ctx.UserData.Add(CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)); + ctx.SaveChanges(); + } + + _userDataManager.SaveUserData( + _user, + item, + new UserItemData { Key = item.GetUserDataKeys()[0], Played = true }, + UserDataSaveReason.UpdateUserRating, + CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(item.Id)).ToList(); + + // The retired-key row is what a re-added item reattaches by, so it survives, but it must + // not keep a playback position that holds the item in Continue Watching. + Assert.Equal( + item.GetUserDataKeys().Append("Author-Old Album-0001Old File Name").OrderBy(e => e, StringComparer.Ordinal), + rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal)); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(0, row.PlaybackPositionTicks); + }); + } + } + [Fact] public void GetUserData_NullUser_ThrowsArgumentNullException() { diff --git a/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs b/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs new file mode 100644 index 0000000000..ba0829cb56 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.Migrations.Routines; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +public sealed class HarmonizeConflictingUserDataTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; + private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private readonly Guid _untouchedId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + public HarmonizeConflictingUserDataTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + public void Dispose() => _connection.Dispose(); + + [Fact] + public async Task Perform_ConflictingRows_CollapsesToMostRecentPlay() + { + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + ctx.BaseItems.Add(new BaseItemEntity { Id = _itemId, Type = typeof(Folder).FullName! }); + ctx.BaseItems.Add(new BaseItemEntity { Id = _untouchedId, Type = typeof(Folder).FullName! }); + + // One item holding rows from two different eras of itself... + ctx.UserData.Add(Row(_itemId, "497698", new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true)); + ctx.UserData.Add(Row(_itemId, "tt3480822", new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490)); + + // ...and one whose rows already agree. + ctx.UserData.Add(Row(_untouchedId, "121", new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 77)); + ctx.UserData.Add(Row(_untouchedId, "tt0167261", new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 77)); + + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await CreateMigration().PerformAsync(TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(_itemId)).ToList(); + Assert.Equal(2, rows.Count); + Assert.All(rows, row => + { + Assert.True(row.Played); + Assert.Equal(0, row.PlaybackPositionTicks); + Assert.Equal(9, row.PlayCount); + }); + + Assert.All(ctx.UserData.Where(e => e.ItemId.Equals(_untouchedId)), row => Assert.Equal(77, row.PlaybackPositionTicks)); + } + } + + [Fact] + public async Task Perform_DetachedRows_AreLeftAlone() + { + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId }); + + // Detached rows belong to no item, so they are not a conflict and must keep their state + // for the next item that claims one of their keys. + ctx.UserData.Add(Row(BaseItemRepository.PlaceholderId, "497698", new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true)); + ctx.UserData.Add(Row(BaseItemRepository.PlaceholderId, "tt3480822", new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490)); + + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await CreateMigration().PerformAsync(TestContext.Current.CancellationToken); + + using (var ctx = CreateDbContext()) + { + var rows = ctx.UserData.Where(e => e.ItemId.Equals(BaseItemRepository.PlaceholderId)).OrderBy(e => e.CustomDataKey).ToList(); + Assert.Equal(0, rows[0].PlaybackPositionTicks); + Assert.Equal(490, rows[1].PlaybackPositionTicks); + } + } + + private UserData Row(Guid itemId, string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false) + { + return new UserData + { + ItemId = itemId, + Item = null, + UserId = _userId, + User = null, + CustomDataKey = key, + LastPlayedDate = lastPlayed, + PlayCount = playCount, + PlaybackPositionTicks = positionTicks, + Played = played + }; + } + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(new Mock().Object, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); + + private HarmonizeConflictingUserData CreateMigration() + { + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny())).ReturnsAsync(CreateDbContext); + + return new HarmonizeConflictingUserData( + new StartupLogger(NullLogger.Instance), + factory.Object); + } +} -- cgit v1.2.3 From 3d52637ce320aaa592008f4a05cfc3613e0d36fe Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 5 Oct 2026 19:18:43 -0400 Subject: Backport pull request #18093 from jellyfin/release-12.z Stop rebuilding the inherited-tag set per row, and count only the people /Persons can return Original-merge: 63b5a050a9dc943854bae01856f30f1a5a089245 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 5 + .../Item/BaseItemRepository.QueryBuilding.cs | 18 ++- .../Item/BaseItemRepository.TranslateQuery.cs | 6 +- .../Item/PeopleRepository.cs | 7 + .../Entities/InternalPeopleQuery.cs | 7 + .../Item/BaseItemRepositoryInheritedTagTests.cs | 175 +++++++++++++++++++++ .../Item/PeopleRepositoryMustHaveItemTests.cs | 85 ++++++++++ 7 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 6f3df084c0..2d62fdab48 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3657,6 +3657,11 @@ namespace Emby.Server.Implementations.Library public QueryResult GetPeopleItems(InternalPeopleQuery query) { + ArgumentNullException.ThrowIfNull(query); + + // This hands back by-name items, so the people without one are not ours to report. + query.MustHaveItem = true; + var queryResult = _peopleRepository.GetPeople(query); var baseItems = queryResult.Items.Select(i => { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 1a4c9da41c..4cd02bcd8d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -612,11 +612,12 @@ public sealed partial class BaseItemRepository var blockedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds); baseQuery = baseQuery.Where(e => !blockedTagItemIds.Contains(e.Id) && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) - && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !blockedByAncestor.Contains(e.Id) && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } @@ -629,10 +630,12 @@ public sealed partial class BaseItemRepository .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds); + baseQuery = baseQuery.Where(e => allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) - || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || allowedByAncestor.Contains(e.Id) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) // People don't carry the tags of the media they appear in and would never match @@ -642,6 +645,17 @@ public sealed partial class BaseItemRepository return baseQuery; } + /// + /// Reads back the items that carry one of the tagged items as an ancestor. + /// + /// The database context. + /// The ids of the items carrying the tag. + /// The ids of the items below one of them. + private static IQueryable ItemsBelowTaggedAncestor(JellyfinDbContext context, IQueryable taggedItemIds) + => context.AncestorIds + .Where(a => taggedItemIds.Contains(a.ParentItemId)) + .Select(a => a.ItemId); + /// /// Builds a filter expression for max parental rating that handles both rated items /// and unrated BoxSets/Playlists (which check linked children's ratings). diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index a7110485b4..e66f18bc6e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1132,11 +1132,12 @@ public sealed partial class BaseItemRepository var blockedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds); baseQuery = baseQuery.Where(e => !blockedTagItemIds.Contains(e.Id) && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) - && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !blockedByAncestor.Contains(e.Id) && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); } @@ -1148,11 +1149,12 @@ public sealed partial class BaseItemRepository var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); + var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds); baseQuery = baseQuery.Where(e => allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) - || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || allowedByAncestor.Contains(e.Id) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) // People don't carry the tags of the media they appear in and would never match diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index fcddc09ad9..bc79699486 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -423,6 +423,13 @@ public class PeopleRepository(IDbContextFactory dbProvider, I .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId))); } + if (filter.MustHaveItem) + { + // A credit with no by-name item behind it cannot be handed back as one. + var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + query = query.Where(e => context.BaseItems.Any(b => b.Type == personType && b.Name == e.Name)); + } + if (!filter.ItemId.IsEmpty()) { var itemId = filter.ItemId; diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 8d2a959f4d..50f749367f 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -65,5 +65,12 @@ namespace MediaBrowser.Controller.Entities /// people must satisfy through at least one of the items they are credited on. /// public InternalItemsQuery AccessFilter { get; set; } + + /// + /// Gets or sets a value indicating whether to keep only people that have a by-name item behind + /// them. Set by callers that hand back items rather than credits, so the count they report + /// describes the same people they can return. + /// + public bool MustHaveItem { get; set; } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs new file mode 100644 index 0000000000..d995293923 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs @@ -0,0 +1,175 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers the inherited-tag filters a user's blocked and allowed tags turn into. A tag reaches an item +/// four ways - on the item, on its series, on an ancestor and on its library - and each of them has to +/// keep answering the same after the ancestor check moved off a per-row correlated subquery. +/// +public sealed class BaseItemRepositoryInheritedTagTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private const string Tag = "Adult"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _otherLibrary = Guid.NewGuid(); + + // Tagged on the item itself. + private readonly Guid _taggedSeries = Guid.NewGuid(); + + // Inherits the tag from the series it belongs to, without an ancestor row for it. + private readonly Guid _episodeOfTaggedSeries = Guid.NewGuid(); + + // Inherits the tag from a season in the middle of its ancestor chain. + private readonly Guid _taggedSeason = Guid.NewGuid(); + private readonly Guid _episodeUnderTaggedSeason = Guid.NewGuid(); + + // Inherits the tag from the library above it. + private readonly Guid _taggedLibrarySeries = Guid.NewGuid(); + + // Carries the tag nowhere, the control the assertions are read against. + private readonly Guid _untaggedSeries = Guid.NewGuid(); + private readonly Guid _untaggedEpisode = Guid.NewGuid(); + + public BaseItemRepositoryInheritedTagTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void ExcludeInheritedTags_DropsEveryItemTheTagReaches() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = [Tag] }).ToHashSet(); + + // The blocked library carries the tag itself, so it goes with everything under it. + Assert.Equal( + new[] { _library, _untaggedSeries, _untaggedEpisode }.Order(), + ids.Order()); + } + + [Fact] + public void IncludeInheritedTags_KeepsExactlyTheItemsTheTagReaches() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { IncludeInheritedTags = [Tag] }).ToHashSet(); + + Assert.Equal( + new[] { _otherLibrary, _taggedSeries, _episodeOfTaggedSeries, _taggedSeason, _episodeUnderTaggedSeason, _taggedLibrarySeries }.Order(), + ids.Order()); + } + + [Fact] + public void ExcludeInheritedTags_DropsAnItemReachedOnlyThroughAnAncestor() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Episode], + ExcludeInheritedTags = [Tag] + }); + + Assert.Equal([_untaggedEpisode], ids); + } + + [Fact] + public void ExcludeInheritedTags_WithAnUnusedTag_KeepsEverything() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = ["Unused"] }); + + Assert.Equal(9, ids.Count); + } + + private void Seed(JellyfinDbContext context) + { + AddItem(context, _library, FolderType, "Shows", true); + AddItem(context, _otherLibrary, FolderType, "Blocked library", true); + AddItem(context, _taggedSeries, SeriesType, "Tagged series", true); + AddItem(context, _episodeOfTaggedSeries, EpisodeType, "Episode of tagged series", false, _taggedSeries); + AddItem(context, _taggedSeason, SeasonType, "Tagged season", true); + AddItem(context, _episodeUnderTaggedSeason, EpisodeType, "Episode under tagged season", false); + AddItem(context, _taggedLibrarySeries, SeriesType, "Series in blocked library", true); + AddItem(context, _untaggedSeries, SeriesType, "Untagged series", true); + AddItem(context, _untaggedEpisode, EpisodeType, "Untagged episode", false, _untaggedSeries); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. The + // episode of the tagged series deliberately has none, so the series branch is what has to catch it. + AddAncestors(context, _taggedSeries, _library); + AddAncestors(context, _taggedSeason, _library); + AddAncestors(context, _episodeUnderTaggedSeason, _taggedSeason, _library); + AddAncestors(context, _taggedLibrarySeries, _otherLibrary); + AddAncestors(context, _untaggedSeries, _library); + AddAncestors(context, _untaggedEpisode, _untaggedSeries, _library); + + Tagged(context, _taggedSeries, _taggedSeason, _otherLibrary); + + context.SaveChanges(); + } + + private void AddItem(JellyfinDbContext context, Guid id, string type, string name, bool isFolder, Guid? seriesId = null) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = type, + Name = name, + IsFolder = isFolder, + SeriesId = seriesId + }); + } + + private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + private void Tagged(JellyfinDbContext context, params Guid[] itemIds) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = Tag, + CleanValue = Tag.GetCleanValue() + }; + + context.ItemValues.Add(itemValue); + foreach (var itemId in itemIds) + { + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = itemId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs new file mode 100644 index 0000000000..f143ce1e20 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs @@ -0,0 +1,85 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers . A caller that hands back by-name items can +/// only return the people that have one, so the count it reports has to leave out the rest rather than +/// describe a larger set than it can page through. +/// +public sealed class PeopleRepositoryMustHaveItemTests : SqliteDbTestFixture +{ + private readonly PeopleRepository _people; + private readonly Guid _movie = Guid.NewGuid(); + + public PeopleRepositoryMustHaveItemTests() + { + var lookup = new ItemTypeLookup(); + using (var context = CreateDbContext()) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = _movie, + Name = "Movie", + Type = lookup.BaseItemKindNames[BaseItemKind.Movie] + }); + + // Only one of the two credits has a by-name item behind it. + context.BaseItems.Add(new BaseItemEntity + { + Id = Guid.NewGuid(), + Name = "With Item", + Type = lookup.BaseItemKindNames[BaseItemKind.Person] + }); + + context.SaveChanges(); + } + + _people = new PeopleRepository(CreateDbContextFactory(), lookup, Mock.Of()); + _people.UpdatePeople(_movie, [ + new PersonInfo { Name = "With Item", Type = PersonKind.Actor }, + new PersonInfo { Name = "Without Item", Type = PersonKind.Actor } + ]); + } + + [Fact] + public void WithoutMustHaveItem_ReturnsAndCountsEveryCredit() + { + var result = _people.GetPeople(new InternalPeopleQuery()); + + Assert.Equal(2, result.TotalRecordCount); + Assert.Equal(2, result.Items.Count); + } + + [Fact] + public void MustHaveItem_DropsTheCreditWithoutAnItem() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true }); + + Assert.Equal("With Item", Assert.Single(result.Items).Name); + } + + [Fact] + public void MustHaveItem_CountsOnlyWhatItCanReturn() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true }); + + Assert.Equal(1, result.TotalRecordCount); + } + + [Fact] + public void MustHaveItem_CountAgreesWithThePageWhenLimited() + { + var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true, Limit = 10 }); + + Assert.Equal(result.Items.Count, result.TotalRecordCount); + } +} -- cgit v1.2.3 From 3f087eb4a54c7088e381d9e466a2978b6fb30e30 Mon Sep 17 00:00:00 2001 From: excelsior-dev <44299346+excelsior-dev@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:18:49 -0400 Subject: Backport pull request #18223 from jellyfin/release-12.z Enhance internal search provider for multiple scripts Original-merge: 9ea203c47aa9f9723a22853151bf8291be3ed330 Merged-by: crobibero Backported-by: Cody Robibero --- CONTRIBUTORS.md | 1 + .../Library/Search/SqlSearchProvider.cs | 31 +++++++++++++++++++--- .../Library/SqlSearchProviderTests.cs | 10 ++++++- 3 files changed, 37 insertions(+), 5 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index aca9d6b790..8240b458e6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -244,6 +244,7 @@ - [Oggeb1](https://github.com/Oggeb1) - [scr4bble](https://github.com/scr4bble) - [Mateus Bandeira](https://github.com/mateusbandeiraa) + - [excelsior](https://github.com/excelsior-dev) - [TheDreadPirate](https://github.com/thedreaddpirate) - [klizas](https://github.com/klizas) diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index 2cbfb6a4fa..a5ea02df51 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -10,6 +10,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; @@ -36,6 +37,7 @@ public class SqlSearchProvider : IInternalSearchProvider private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IItemQueryHelpers _queryHelpers; + private readonly IServerConfigurationManager _configurationManager; /// /// Initializes a new instance of the class. @@ -45,18 +47,21 @@ public class SqlSearchProvider : IInternalSearchProvider /// The library manager. /// The user manager. /// The shared item query helpers. + /// The configuration manager. public SqlSearchProvider( IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup, ILibraryManager libraryManager, IUserManager userManager, - IItemQueryHelpers queryHelpers) + IItemQueryHelpers queryHelpers, + IServerConfigurationManager configurationManager) { _dbProvider = dbProvider; _itemTypeLookup = itemTypeLookup; _libraryManager = libraryManager; _userManager = userManager; _queryHelpers = queryHelpers; + _configurationManager = configurationManager; } /// @@ -98,6 +103,12 @@ public class SqlSearchProvider : IInternalSearchProvider // so match it via a case-insensitive LIKE rather than a per-row case conversion // that may not translate to SQL on every provider. var likeOriginal = $"%{rawSearchTerm}%"; + + // Great benefit for users with metadata in non-latin script + // since SortName is among other things stored transliterated. + var sortNameShape = BaseItem.GetSortName(rawSearchTerm, true, _configurationManager.Configuration); + var likeSortName = string.IsNullOrWhiteSpace(sortNameShape) ? null : $"%{sortNameShape}%"; + var limit = query.Limit ?? DefaultSearchLimit; var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); @@ -107,9 +118,21 @@ public class SqlSearchProvider : IInternalSearchProvider var dbQuery = dbContext.BaseItems .AsNoTracking() .Where(e => e.Id != _placeholderId) - .Where(e => !e.IsVirtualItem) - .Where(e => e.CleanName!.Contains(cleanSearchTerm) - || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + .Where(e => !e.IsVirtualItem); + + if (likeSortName is null) + { + dbQuery = dbQuery + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + } + else + { + dbQuery = dbQuery + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)) + || (e.SortName != null && EF.Functions.Like(e.SortName, likeSortName))); + } dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs index 5aa770b9b3..89ca037256 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs @@ -9,8 +9,11 @@ using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.Implementations.Tests.Item; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Configuration; using Moq; using Xunit; using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; @@ -52,12 +55,17 @@ public sealed class SqlSearchProviderTests : SqliteDbTestFixture var userManager = new Mock(); userManager.Setup(u => u.GetUserById(_user.Id)).Returns(_user); + var serverConfigurationManager = new Mock(); + serverConfigurationManager.Setup(scm => scm.Configuration) + .Returns(new ServerConfiguration()); + _provider = new SqlSearchProvider( CreateDbContextFactory(), itemTypeLookup, _libraryManager.Object, userManager.Object, - CreateBaseItemRepository(itemTypeLookup)); + CreateBaseItemRepository(itemTypeLookup), + serverConfigurationManager.Object); } [Fact] -- cgit v1.2.3 From d624b7c57ae586dcaba61b45b6695031ebfba371 Mon Sep 17 00:00:00 2001 From: z0rimo <46391780+z0rimo@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:18:51 -0400 Subject: Backport pull request #18236 from jellyfin/release-12.z Fix user preferences when renaming libraries Original-merge: 9203258175c32b44052d2a0fc5bae9815697c80b Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 33 ++-- .../Controllers/LibraryStructureController.cs | 188 +++++++++++++++++---- MediaBrowser.Controller/Library/ILibraryManager.cs | 18 ++ .../Controllers/LibraryStructureControllerTests.cs | 125 ++++++++++++++ 4 files changed, 319 insertions(+), 45 deletions(-) (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2d62fdab48..b4f6f57bf6 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3052,14 +3052,7 @@ namespace Emby.Server.Implementations.Library CollectionType? viewType, string sortName) { - var parentIdString = parentId.IsEmpty() - ? null - : parentId.ToString("N", CultureInfo.InvariantCulture); - - // The name is either localized (grouped views) or the library folder's own name. - var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); - - var id = GetNewItemId(idValues, typeof(UserView)); + var id = GetNamedViewId(user, parentId, viewType); var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N", CultureInfo.InvariantCulture)); @@ -3117,6 +3110,18 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType) + { + var parentIdString = parentId.IsEmpty() + ? null + : parentId.ToString("N", CultureInfo.InvariantCulture); + + // The name is either localized (grouped views) or the library folder's own name. + var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetShadowView( BaseItem parent, CollectionType? viewType, @@ -3126,10 +3131,7 @@ namespace Emby.Server.Implementations.Library var name = parent.Name; var parentId = parent.Id; - - var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); - - var id = GetNewItemId(idValues, typeof(UserView)); + var id = GetShadowViewId(name, parentId, viewType); var path = parent.Path; @@ -3181,6 +3183,13 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType) + { + var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetNamedView( string name, Guid parentId, diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 7f63e410a5..9e6bfde085 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -10,6 +10,10 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -36,6 +40,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; private readonly IDirectoryService _directoryService; + private readonly IUserManager _userManager; /// /// Initializes a new instance of the class. @@ -44,16 +49,19 @@ public class LibraryStructureController : BaseJellyfinApiController /// Instance of interface. /// Instance of interface. /// Instance of interface. + /// Instance of interface. public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, - IDirectoryService directoryService) + IDirectoryService directoryService, + IUserManager userManager) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _directoryService = directoryService; + _userManager = userManager; } /// @@ -138,7 +146,7 @@ public class LibraryStructureController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public ActionResult RenameVirtualFolder( + public async Task RenameVirtualFolder( [FromQuery] string? name, [FromQuery] string? newName, [FromQuery] bool refreshLibrary = false) @@ -173,6 +181,12 @@ public class LibraryStructureController : BaseJellyfinApiController return Conflict($"The media library already exists at {newPath}."); } + var oldLibrary = FindLibrary(currentPath); + // Use the stored ID when possible. The case sensitivity setting may have changed since the library was created. + var oldLibraryId = oldLibrary?.Id ?? _libraryManager.GetNewItemId(currentPath, typeof(CollectionFolder)); + var oldLibraryName = oldLibrary?.Name ?? Path.GetFileName(currentPath); + var newLibraryId = _libraryManager.GetNewItemId(newPath, typeof(CollectionFolder)); + _libraryMonitor.Stop(); try @@ -188,47 +202,155 @@ public class LibraryStructureController : BaseJellyfinApiController } _directoryService.Move(currentPath, newPath); + + await ReplaceLibraryIdInUserPreferencesAsync( + oldLibraryId, + newLibraryId, + oldLibraryName, + Path.GetFileName(newPath), + oldLibrary?.CollectionType).ConfigureAwait(false); } finally { CollectionFolder.OnCollectionFolderChange(); - Task.Run(async () => + _ = Task.Run(() => RefreshAfterRenameAsync(newPath, refreshLibrary)); + } + + return NoContent(); + } + + private async Task RefreshAfterRenameAsync(string newPath, bool refreshLibrary) + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); + var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); + if (newLib is CollectionFolder folder) { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) + _libraryManager.ClearIgnoreRuleCache(); + foreach (var child in folder.GetPhysicalFolders()) { - await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); - if (newLib is CollectionFolder folder) - { - _libraryManager.ClearIgnoreRuleCache(); - foreach (var child in folder.GetPhysicalFolders()) - { - await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); - await child.ValidateChildren(new Progress(), CancellationToken.None).ConfigureAwait(false); - } - } - else - { - _libraryManager.ClearIgnoreRuleCache(); - // We don't know if this one can be validated individually, trigger a new validation - _libraryManager.QueueLibraryScan(); - } - - _libraryManager.ClearIgnoreRuleCache(); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); + await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); + await child.ValidateChildren(new Progress(), CancellationToken.None).ConfigureAwait(false); } - }); + } + else + { + _libraryManager.ClearIgnoreRuleCache(); + // We don't know if this one can be validated individually, trigger a new validation + _libraryManager.QueueLibraryScan(); + } + + _libraryManager.ClearIgnoreRuleCache(); } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + } - return NoContent(); + private CollectionFolder? FindLibrary(string path) + => _libraryManager.GetUserRootFolder().Children + .OfType() + .FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase)); + + private async Task ReplaceLibraryIdInUserPreferencesAsync( + Guid oldId, + Guid newId, + string oldName, + string newName, + CollectionType? viewType) + { + var shadowView = ( + Old: _libraryManager.GetShadowViewId(oldName, oldId, viewType), + New: _libraryManager.GetShadowViewId(newName, newId, viewType)); + + if (oldId.Equals(newId) && shadowView.Old.Equals(shadowView.New)) + { + return; + } + + PreferenceKind[] libraryPreferences = + [ + PreferenceKind.EnabledFolders, + PreferenceKind.BlockedMediaFolders, + PreferenceKind.EnableContentDeletionFromFolders, + PreferenceKind.LatestItemExcludes, + PreferenceKind.MyMediaExcludes, + PreferenceKind.GroupedFolders, + PreferenceKind.OrderedViews + ]; + + // These lists hold what the "My Media" row shows, which includes the views built from a library. + PreferenceKind[] viewPreferences = [PreferenceKind.MyMediaExcludes, PreferenceKind.OrderedViews]; + + foreach (var user in _userManager.GetUsers()) + { + var libraryMap = new Dictionary { [oldId] = newId }; + var viewMap = new Dictionary(libraryMap) + { + [shadowView.Old] = shadowView.New, + [_libraryManager.GetNamedViewId(user, oldId, viewType)] = _libraryManager.GetNamedViewId(user, newId, viewType) + }; + + var changed = false; + + foreach (var kind in libraryPreferences) + { + changed |= ReplaceIds(user, kind, viewPreferences.Contains(kind) ? viewMap : libraryMap); + } + + if (changed) + { + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + } + } + } + + private static bool ReplaceIds(User user, PreferenceKind kind, Dictionary map) + { + var values = user.GetPreference(kind); + var rewritten = new List(values.Length); + var seen = new HashSet(); + var touched = false; + + foreach (var value in values) + { + // Clients write these in both the dashed and the plain form, so compare them parsed. + if (!Guid.TryParse(value, out var parsed)) + { + rewritten.Add(value); + continue; + } + + var isReplaced = map.TryGetValue(parsed, out var replacement) && !replacement.Equals(parsed); + if (isReplaced) + { + parsed = replacement; + touched = true; + } + + if (!seen.Add(parsed)) + { + touched = true; + continue; + } + + var format = value.Contains('-', StringComparison.Ordinal) ? "D" : "N"; + rewritten.Add(isReplaced ? parsed.ToString(format, CultureInfo.InvariantCulture) : value); + } + + if (touched) + { + user.SetPreference(kind, rewritten.ToArray()); + } + + return touched; } /// diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 82de3546f0..ae15c75350 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -483,6 +483,15 @@ namespace MediaBrowser.Controller.Library string sortName, string uniqueId); + /// + /// Gets the identifier of a named user view. + /// + /// The user. + /// The parent identifier. + /// Type of the view. + /// The named view identifier. + Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType); + /// /// Gets the shadow view. /// @@ -495,6 +504,15 @@ namespace MediaBrowser.Controller.Library CollectionType? viewType, string sortName); + /// + /// Gets the identifier of a shadow view. + /// + /// The view name. + /// The parent identifier. + /// Type of the view. + /// The shadow view identifier. + Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType); + /// /// Gets the season number from path. /// diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index 79b9d1e2c5..3d0840b776 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -1,12 +1,19 @@ using System; +using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Http.Json; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -218,4 +225,122 @@ public sealed class LibraryStructureControllerTests : IClassFixture(); + var userManager = _factory.Services.GetRequiredService(); + var rootFolderPath = _factory.Services.GetRequiredService().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&collectionType=movies&refreshLibrary=true", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, NewName), typeof(CollectionFolder)); + Assert.NotEqual(oldId, newId); + + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType().Single(f => f.Id.Equals(oldId)); + + var otherId = Guid.NewGuid(); + var user = await userManager.CreateUserAsync("rename-prefs-user"); + user.SetPermission(PermissionKind.EnableAllFolders, false); + user.SetPreference(PreferenceKind.EnabledFolders, [otherId, oldId, newId]); + user.SetPreference(PreferenceKind.BlockedMediaFolders, [oldId]); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, [oldId, otherId]); + user.SetPreference(PreferenceKind.LatestItemExcludes, [oldId]); + + // The views built from the library take its id and name as input, so they move with it. + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var oldUserViewId = libraryManager.GetNamedView(user, oldLibrary.Name, oldId, oldLibrary.CollectionType, string.Empty).Id; + user.SetPreference(PreferenceKind.MyMediaExcludes, [otherId.ToString(), oldId.ToString(), oldShadowId.ToString("N", CultureInfo.InvariantCulture)]); + user.SetPreference(PreferenceKind.GroupedFolders, [oldId]); + user.SetPreference(PreferenceKind.OrderedViews, [otherId.ToString(), oldUserViewId.ToString(), oldShadowId.ToString(), oldId.ToString()]); + user.SetPreference(PreferenceKind.BlockedTags, ["keep"]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={NewName}&refreshLibrary=true", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + var reloaded = userManager.GetUserById(user.Id)!; + Assert.Equal([otherId, newId], reloaded.GetPreferenceValues(PreferenceKind.EnabledFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues(PreferenceKind.BlockedMediaFolders)); + Assert.Equal([newId, otherId], reloaded.GetPreferenceValues(PreferenceKind.EnableContentDeletionFromFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues(PreferenceKind.LatestItemExcludes)); + // The endpoint refreshes in the background; make sure the renamed library has been picked up. + await libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true); + var newLibrary = libraryManager.GetUserRootFolder().Children.OfType().Single(f => f.Id.Equals(newId)); + var newShadowId = libraryManager.GetShadowView(newLibrary, newLibrary.CollectionType, string.Empty).Id; + var newUserViewId = libraryManager.GetNamedView(reloaded, newLibrary.Name, newId, newLibrary.CollectionType, string.Empty).Id; + Assert.NotEqual(oldShadowId, newShadowId); + Assert.NotEqual(oldUserViewId, newUserViewId); + Assert.Equal( + [otherId.ToString(), newId.ToString(), newShadowId.ToString("N", CultureInfo.InvariantCulture)], + reloaded.GetPreference(PreferenceKind.MyMediaExcludes)); + Assert.Equal([newId], reloaded.GetPreferenceValues(PreferenceKind.GroupedFolders)); + Assert.Equal( + [otherId.ToString(), newUserViewId.ToString(), newShadowId.ToString(), newId.ToString()], + reloaded.GetPreference(PreferenceKind.OrderedViews)); + Assert.Equal(["keep"], reloaded.GetPreference(PreferenceKind.BlockedTags)); + + // With the block list cleared, the enabled list alone must let the user see the renamed library. + reloaded.SetPreference(PreferenceKind.BlockedMediaFolders, Array.Empty()); + await userManager.UpdateUserAsync(reloaded); + Assert.True(newLibrary.IsVisible(userManager.GetUserById(user.Id)!)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={NewName}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_CapitalizationOnly_LeavesPreferencesAlone() + { + const string Name = "rename-case-test"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService(); + var userManager = _factory.Services.GetRequiredService(); + var rootFolderPath = _factory.Services.GetRequiredService().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name.ToUpperInvariant()), typeof(CollectionFolder)); + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType().Single(f => f.Id.Equals(oldId)); + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var newShadowId = libraryManager.GetShadowViewId(Name.ToUpperInvariant(), newId, oldLibrary.CollectionType); + + var user = await userManager.CreateUserAsync("rename-case-user"); + user.SetPreference(PreferenceKind.EnabledFolders, [oldId]); + user.SetPreference(PreferenceKind.MyMediaExcludes, [oldShadowId]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={Name.ToUpperInvariant()}&refreshLibrary=false", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + Assert.Equal([newId], userManager.GetUserById(user.Id)!.GetPreferenceValues(PreferenceKind.EnabledFolders)); + Assert.Equal([newShadowId], userManager.GetUserById(user.Id)!.GetPreferenceValues(PreferenceKind.MyMediaExcludes)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name.ToUpperInvariant()}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } } -- cgit v1.2.3 From 3114520c87579eb5cf150e4d764f59fd32f39412 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 5 Oct 2026 19:19:07 -0400 Subject: Backport pull request #18279 from jellyfin/release-12.z Recompute the parental rating score whenever an item is saved Original-merge: 29d7a3ca2444070bda748161a2da777c8e42594e Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/LibraryManager.cs | 6 ++ .../LibraryManager/ParentalRatingScoreTests.cs | 112 +++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs (limited to 'Emby.Server.Implementations/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index b4f6f57bf6..16616dd328 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2452,6 +2452,9 @@ namespace Emby.Server.Implementations.Library var parentCollectionType = parent is not null ? GetTopFolderContentType(parent) : null; foreach (var item in items) { + // Parental controls filter on the stored score, so it has to match the ratings being saved. + item.OnMetadataChanged(); + if (item is Video video && video.LocalAlternateVersions.Length > 0) { var videoType = video.GetType(); @@ -2658,6 +2661,9 @@ namespace Emby.Server.Implementations.Library { foreach (var item in items) { + // Parental controls filter on the stored score, so it has to match the ratings being saved. + item.OnMetadataChanged(); + item.DateLastSaved = DateTime.UtcNow; await RunMetadataSavers(item, updateReason).ConfigureAwait(false); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs new file mode 100644 index 0000000000..7adf7ce3b3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ParentalRatingScoreTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +[Collection("LibraryManagerTests")] +public sealed class ParentalRatingScoreTests : IDisposable +{ + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly List _saved = []; + private readonly ILibraryManager? _previousLibraryManager; + private readonly ILocalizationManager? _previousLocalizationManager; + + public ParentalRatingScoreTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + fixture.Freeze>() + .Setup(p => p.SaveItems(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((items, _) => _saved.AddRange(items)); + + _libraryManager = fixture.Build() + .Do(s => s.AddParts( + fixture.Create>(), + [], + fixture.Create>(), + fixture.Create>(), + fixture.Create>())) + .Create(); + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousLocalizationManager = BaseItem.LocalizationManager; + BaseItem.LibraryManager = _libraryManager; + + var localizationManagerMock = new Mock(); + localizationManagerMock.Setup(l => l.GetRatingScore("G", It.IsAny())).Returns(new ParentalRatingScore(0, null)); + localizationManagerMock.Setup(l => l.GetRatingScore("XXX", It.IsAny())).Returns(new ParentalRatingScore(1000, null)); + BaseItem.LocalizationManager = localizationManagerMock.Object; + Video.RecordingsManager ??= fixture.Create(); + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.LocalizationManager = _previousLocalizationManager!; + } + + private static Movie CreateRatedMovie() => new() + { + Id = Guid.NewGuid(), + Name = "Up", + PreferredMetadataCountryCode = "us", + OfficialRating = "G", + InheritedParentalRatingValue = 0 + }; + + [Fact] + public async Task UpdateItemAsync_ChangedCustomRating_StoresItsScore() + { + var movie = CreateRatedMovie(); + movie.CustomRating = "XXX"; + + await _libraryManager.UpdateItemAsync(movie, null!, ItemUpdateType.MetadataEdit, CancellationToken.None); + + Assert.Equal(1000, Assert.Single(_saved).InheritedParentalRatingValue); + } + + [Fact] + public async Task UpdateItemAsync_ClearedRatings_ClearsScore() + { + var movie = CreateRatedMovie(); + movie.OfficialRating = null; + + await _libraryManager.UpdateItemAsync(movie, null!, ItemUpdateType.MetadataEdit, CancellationToken.None); + + Assert.Null(Assert.Single(_saved).InheritedParentalRatingValue); + } + + [Fact] + public void CreateItem_RatedItem_StoresItsScore() + { + var movie = CreateRatedMovie(); + movie.InheritedParentalRatingValue = null; + movie.CustomRating = "XXX"; + + _libraryManager.CreateItem(movie, null); + + Assert.Equal(1000, _saved.Single().InheritedParentalRatingValue); + } +} -- cgit v1.2.3