Merge pull request #17881 from Shadowghost/fix-user-views

Fix /UserViews exhausting memory and reporting random child counts
This commit is contained in:
Cody Robibero authored and GitHub committed 2026-09-14 19:30:40 -04:00
commit a838a06aa5
14 files changed
+605 -83

No files matched your search

+90 -15
View File
@@ -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>())
{
return Random.Shared.Next(1, 10);
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)
{
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)
@@ -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)
{
@@ -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);
}
@@ -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 />
@@ -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 })
@@ -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 })
@@ -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());
}
return FlattenItems(this, expandedFolders)
.SelectMany(LibraryManager.GetCollectionFolders)
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(linkedChildren, expandedFolders)
.SelectMany(i => LibraryManager.GetCollectionFolders(i, rootChildren))
.Select(i => i.Id)
.Distinct()
.ToArray();
@@ -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 = 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 =>
{
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
var isPlayedValue = query.IsPlayed.Value;
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)
{
+16 -4
View File
@@ -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)
@@ -21,6 +21,16 @@ public static class DescendantQueryHelper
public static Expression<Func<BaseItemEntity, bool>> IsCountableLeaf { get; } =
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
@@ -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;
}
}
@@ -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");
@@ -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));
}
}
@@ -255,6 +255,146 @@ public sealed class ItemCountServiceTests : IDisposable
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()
{