mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-10-26 16:45:09 +00:00
Merge pull request #17881 from Shadowghost/fix-user-views
Fix /UserViews exhausting memory and reporting random child counts
This commit is contained in:
14 files changed
+605
-83
No files matched your search
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user