diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 3ba6750045..99e85d946d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -121,7 +121,9 @@ public sealed class BaseItemRepository var date = (DateTime?)DateTime.UtcNow; - var descendantIds = ids.SelectMany(f => DescendantQueryHelper.GetAllDescendantIds(context, f)).ToHashSet(); + // Use owned-only traversal (AncestorIds) to avoid deleting items that are merely + // linked via LinkedChildren (e.g. movies/series inside a BoxSet are associations, not owned children). + var descendantIds = ids.SelectMany(f => DescendantQueryHelper.GetOwnedDescendantIds(context, f)).ToHashSet(); foreach (var id in ids) { descendantIds.Add(id); @@ -333,6 +335,7 @@ public sealed class BaseItemRepository } var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter) + .AsSplitQuery() .AsEnumerable() .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) .Where(dto => dto is not null) @@ -341,7 +344,7 @@ public sealed class BaseItemRepository return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!; } - dbQuery = ApplyNavigations(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter).AsSplitQuery(); return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; } @@ -994,7 +997,7 @@ public sealed class BaseItemRepository if (filter.CollapseBoxSetItems == true) { - dbQuery = ApplyBoxSetCollapsing(context, dbQuery); + dbQuery = ApplyBoxSetCollapsing(context, dbQuery, filter.CollapseBoxSetItemTypes); } dbQuery = ApplyOrder(dbQuery, filter, context); @@ -1004,12 +1007,55 @@ public sealed class BaseItemRepository private IQueryable ApplyBoxSetCollapsing( JellyfinDbContext context, - IQueryable dbQuery) + IQueryable dbQuery, + BaseItemKind[] collapsibleTypes) { var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; var currentIds = dbQuery.Select(e => e.Id); + if (collapsibleTypes.Length == 0) + { + // Collapse all item types into box sets + return ApplyBoxSetCollapsingAll(context, currentIds, boxSetTypeName); + } + + // Only collapse specific item types, keep others untouched + var collapsibleTypeNames = collapsibleTypes.Select(t => _itemTypeLookup.BaseItemKindNames[t]).ToList(); + + // Items whose type is NOT collapsible (always kept in results) + var nonCollapsibleIds = currentIds + .Where(id => !context.BaseItems.Any(bi => bi.Id == id && collapsibleTypeNames.Contains(bi.Type))); + + // Collapsible items that are NOT in any box set (kept in results) + var collapsibleNotInBoxSet = currentIds + .Where(id => + context.BaseItems.Any(bi => bi.Id == id && collapsibleTypeNames.Contains(bi.Type)) + && !context.BaseItems.Any(bs => bs.Id == id && bs.Type == boxSetTypeName) + && !context.LinkedChildren.Any(lc => + lc.ChildId == id + && lc.ChildType == DbLinkedChildType.Manual + && context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName))); + + // Box set IDs containing at least one accessible collapsible child item + var boxSetIds = context.LinkedChildren + .Where(lc => + lc.ChildType == DbLinkedChildType.Manual + && currentIds.Contains(lc.ChildId) + && context.BaseItems.Any(bi => bi.Id == lc.ChildId && collapsibleTypeNames.Contains(bi.Type)) + && context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName)) + .Select(lc => lc.ParentId) + .Distinct(); + + var collapsedIds = nonCollapsibleIds.Union(collapsibleNotInBoxSet).Union(boxSetIds); + return context.BaseItems.Where(e => collapsedIds.Contains(e.Id)); + } + + private static IQueryable ApplyBoxSetCollapsingAll( + JellyfinDbContext context, + IQueryable currentIds, + string boxSetTypeName) + { // Items that are NOT box sets and NOT in any box set var notInBoxSet = currentIds .Where(id => @@ -1019,8 +1065,7 @@ public sealed class BaseItemRepository && lc.ChildType == DbLinkedChildType.Manual && context.BaseItems.Any(bs => bs.Id == lc.ParentId && bs.Type == boxSetTypeName))); - // Box set IDs containing at least one accessible child item. - // Access filtering is already applied to currentIds via TranslateQuery + // Box set IDs containing at least one accessible child item var boxSetIds = context.LinkedChildren .Where(lc => lc.ChildType == DbLinkedChildType.Manual @@ -1060,8 +1105,10 @@ public sealed class BaseItemRepository dbQuery = dbQuery.Include(e => e.Images); } - // Only include LinkedChildEntities for container types and videos that use them - // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions) + // Include LinkedChildEntities for container types and videos that use them + // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions). + // When IncludeItemTypes is empty (any type may be returned), always include them to ensure + // LinkedChildren are loaded before items are saved back, preventing accidental deletion. var linkedChildTypes = new[] { BaseItemKind.BoxSet, @@ -1070,7 +1117,7 @@ public sealed class BaseItemRepository BaseItemKind.Video, BaseItemKind.Movie }; - if (filter.IncludeItemTypes.Length > 0 && filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) + if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) { dbQuery = dbQuery.Include(e => e.LinkedChildEntities); } @@ -1108,7 +1155,7 @@ public sealed class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); - dbQuery = ApplyNavigations(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter).AsSplitQuery(); return dbQuery; } @@ -1531,7 +1578,9 @@ public sealed class BaseItemRepository ? context.BaseItems .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) .Select(e => new { e.Path, e.Id }) - .ToDictionary(e => e.Path!, e => e.Id) + .AsEnumerable() + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id) : []; var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); @@ -1628,7 +1677,9 @@ public sealed class BaseItemRepository var pathToIdMap = context.BaseItems .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) .Select(e => new { e.Path, e.Id }) - .ToDictionary(e => e.Path!, e => e.Id); + .AsEnumerable() + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); foreach (var path in pathsToResolve) { @@ -3324,6 +3375,13 @@ public sealed class BaseItemRepository .Where(e => e.OwnerId == null); } } + else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0) + { + // Exclude alternate versions from general queries. Alternate versions have + // OwnerId set (pointing to their primary) but no ExtraType. + // Extras (trailers, etc.) also have OwnerId but DO have ExtraType set - keep those. + baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null); + } if (filter.OwnerIds.Length > 0) { diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index cf615788ee..ffdc8421da 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -79,14 +79,27 @@ namespace MediaBrowser.Controller.Entities public CollectionType? CollectionType { get; set; } /// - /// Gets the item's children. + /// Gets or sets the item's children. /// /// /// Our children are actually just references to the ones in the physical root... + /// Setting to null propagates invalidation to physical folders since the getter + /// always delegates to and never reads the backing field. /// /// The actual children. [JsonIgnore] - public override IEnumerable Children => GetActualChildren(); + public override IEnumerable Children + { + get => GetActualChildren(); + set + { + // The getter delegates to physical folders, so invalidate their caches. + foreach (var folder in GetPhysicalFolders(true)) + { + folder.Children = null; + } + } + } [JsonIgnore] public override bool SupportsPeople => false; diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 44903fd4c1..0c0558b4c1 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -733,6 +733,7 @@ namespace MediaBrowser.Controller.Entities if (!query.ForceDirect && RequiresPostFiltering(query)) { query.CollapseBoxSetItems = true; + SetCollapseBoxSetItemTypes(query); } if (this is not UserRootFolder @@ -1039,6 +1040,33 @@ namespace MediaBrowser.Controller.Entities return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query); } + private void SetCollapseBoxSetItemTypes(InternalItemsQuery query) + { + var config = ConfigurationManager.Configuration; + bool collapseMovies = config.EnableGroupingMoviesIntoCollections; + bool collapseSeries = config.EnableGroupingShowsIntoCollections; + + if (collapseMovies && collapseSeries) + { + // Empty means collapse all types + query.CollapseBoxSetItemTypes = []; + return; + } + + var types = new List(); + if (collapseMovies) + { + types.Add(BaseItemKind.Movie); + } + + if (collapseSeries) + { + types.Add(BaseItemKind.Series); + } + + query.CollapseBoxSetItemTypes = types.ToArray(); + } + private static bool AllowBoxSetCollapsing(InternalItemsQuery request) { if (request.IsFavorite.HasValue) diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index b36ea627d8..2824fb6954 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -113,6 +113,12 @@ namespace MediaBrowser.Controller.Entities public bool? CollapseBoxSetItems { get; set; } + /// + /// Gets or sets the item types that should be collapsed into box sets. + /// When empty, all types are collapsed. When set, only items of these types are replaced by their parent box set. + /// + public BaseItemKind[] CollapseBoxSetItemTypes { get; set; } = []; + public string? NameStartsWithOrGreater { get; set; } public string? NameStartsWith { get; set; } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index e6fa6ca458..3bc36dca7a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -30,6 +30,25 @@ public static class DescendantQueryHelper return descendants.AsQueryable(); } + /// + /// Gets a queryable of all owned descendant IDs for a parent item. + /// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations). + /// Use this for deletion to avoid destroying items that are merely linked (e.g. movies in a BoxSet). + /// + /// Database context. + /// Parent item ID. + /// Queryable of owned descendant item IDs. + public static IQueryable GetOwnedDescendantIds(JellyfinDbContext context, Guid parentId) + { + ArgumentNullException.ThrowIfNull(context); + + var descendants = TraverseHierarchyDownOwned(context, [parentId]); + + descendants.Remove(parentId); + + return descendants.AsQueryable(); + } + /// /// Gets a queryable of all folder IDs that have any descendant matching the specified criteria. /// Can be used in LINQ .Contains() expressions. @@ -124,6 +143,47 @@ public static class DescendantQueryHelper return visited; } + /// + /// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren. + /// + private static HashSet TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection startIds) + { + var visited = new HashSet(startIds); + var folderStack = new HashSet(startIds); + + while (folderStack.Count != 0) + { + var currentFolders = folderStack.ToArray(); + folderStack.Clear(); + + var directChildren = context.AncestorIds + .WhereOneOrMany(currentFolders, e => e.ParentItemId) + .Select(e => e.ItemId) + .ToArray(); + + if (directChildren.Length == 0) + { + break; + } + + var childFolders = context.BaseItems + .WhereOneOrMany(directChildren, e => e.Id) + .Where(e => e.IsFolder) + .Select(e => e.Id) + .ToHashSet(); + + foreach (var childId in directChildren) + { + if (visited.Add(childId) && childFolders.Contains(childId)) + { + folderStack.Add(childId); + } + } + } + + return visited; + } + /// /// Traverses UP the hierarchy from items to find all ancestor folders. ///