diff options
14 files changed, 709 insertions, 83 deletions
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<Guid, int>? childCountBatch = null; - if (options.ContainsField(ItemFields.ChildCount)) + if (user is not null && options.ContainsField(ItemFields.ChildCount)) { - var folderIds = accessibleItems.OfType<Folder>().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<Guid, int>? childCountBatch) + private Dictionary<Guid, int>? GetChildCountBatch(IReadOnlyList<BaseItem> 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<Guid, IReadOnlyList<Guid>>? sources = null; + foreach (var folder in items.OfType<Folder>()) + { + var sourceIds = GetChildCountSourceIds(folder, user); + if (sourceIds.Count > 0) + { + (sources ??= new Dictionary<Guid, IReadOnlyList<Guid>>())[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<Guid, int>(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<Guid> 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<CollectionFolder>() + .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<Guid, int>? 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<BaseItemDto>(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)))); } /// <inheritdoc /> 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<Guid, int>(); foreach (var parentId in parentIds) @@ -527,7 +541,11 @@ public class ItemCountService : IItemCountService return result; } - private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual) + private static Dictionary<Guid, int> GetMergedChildCounts( + JellyfinDbContext dbContext, + IQueryable<BaseItemEntity> accessibleItems, + IReadOnlyList<Guid> 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<BaseItem> 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<Guid>(); + return GetLibraryFolderIds(GetLinkedChildren()); + } + + private Guid[] GetLibraryFolderIds(IEnumerable<BaseItem> linkedChildren) + { + // Seeded with this box set so a cycle through a nested collection terminates. + var expandedFolders = new List<Guid> { Id }; + + // The user root children are the same for every item. + var rootChildren = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().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<Folder>().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 @@ -22,6 +22,65 @@ public static class DescendantQueryHelper b => !b.IsFolder && !b.IsVirtualItem; /// <summary> + /// 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. + /// </summary> + public static Expression<Func<BaseItemEntity, bool>> IsDistinctLibraryItem { get; } = + b => !b.PrimaryVersionId.HasValue && (!b.OwnerId.HasValue || b.ExtraType != null); + + /// <summary> + /// 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 + /// <c>VersionResumeData.ApplyTo</c> 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. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The predicate matching the items that user has played.</returns> + public static Expression<Func<BaseItemEntity, bool>> 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)); + + /// <summary> + /// Builds the projection pairing an item's id with <see cref="IsPlayedBy"/> 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. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The projection of each item onto its id and that user's played state.</returns> + public static Expression<Func<BaseItemEntity, LeafPlayedState>> 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<Func<BaseItemEntity, LeafPlayedState>>( + 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); + } + + /// <summary> + /// Builds the negation of <see cref="IsPlayedBy"/>, so a caller filtering for unplayed items reads + /// the same definition of played as one filtering for played items. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The predicate matching the items that user has not played.</returns> + public static Expression<Func<BaseItemEntity, bool>> IsUnplayedBy(Guid userId) + { + var played = IsPlayedBy(userId); + return Expression.Lambda<Func<BaseItemEntity, bool>>(Expression.Not(played.Body), played.Parameters); + } + + /// <summary> /// Gets a queryable of all descendant IDs for a parent item. /// Traverses AncestorIds and LinkedChildren to find all descendants. /// </summary> 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<InternalItemsQuery>()), Times.Never); + } + + private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .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<IReadOnlyList<Guid>>(), It.IsAny<User?>()), + 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<Folder>(); + rootFolder + .Setup(x => x.GetChildren(user, true, It.IsAny<InternalItemsQuery>())) + .Returns<User, bool, InternalItemsQuery>((_, _, _) => [grouped, untyped, shows, ungrouped]); + _libraryManagerMock.Setup(x => x.GetUserRootFolder()).Returns(rootFolder.Object); + + IReadOnlyList<Guid>? counted = null; + _libraryManagerMock + .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>())) + .Callback<IReadOnlyList<Guid>, User?>((ids, _) => counted = ids) + .Returns<IReadOnlyList<Guid>, 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<InternalItemsQuery>())) + .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<IReadOnlyList<Guid>>(), It.IsAny<User?>()), + 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<IUserDataManager>(); + userDataManager + .Setup(m => m.GetUserData(_user, It.IsAny<BaseItem>())) + .Returns((User _, BaseItem item) => new UserItemData { Key = item.Id.ToString("N"), Played = item.Id.Equals(watched.Id) }); + userDataManager + .Setup(m => m.GetResumeUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), _user)) + .Returns(new Dictionary<Guid, VersionResumeData> + { + [onlyWatchedOnAlternate.Id] = new(Guid.NewGuid(), new UserItemData { Key = "alternate", Played = true }) + }); + + var libraryManager = new Mock<ILibraryManager>(); + + 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 @@ -201,6 +201,201 @@ public sealed class ItemCountServiceTests : IDisposable } [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() { var seriesA = Guid.NewGuid(); |
