mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
Fix BoxSet collapse handling and deletion
This commit is contained in:
@@ -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<BaseItemEntity> ApplyBoxSetCollapsing(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> dbQuery)
|
||||
IQueryable<BaseItemEntity> 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<BaseItemEntity> ApplyBoxSetCollapsingAll(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<Guid> 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)
|
||||
{
|
||||
|
||||
@@ -79,14 +79,27 @@ namespace MediaBrowser.Controller.Entities
|
||||
public CollectionType? CollectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item's children.
|
||||
/// Gets or sets the item's children.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="GetActualChildren"/> and never reads the backing field.
|
||||
/// </remarks>
|
||||
/// <value>The actual children.</value>
|
||||
[JsonIgnore]
|
||||
public override IEnumerable<BaseItem> Children => GetActualChildren();
|
||||
public override IEnumerable<BaseItem> 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;
|
||||
|
||||
@@ -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<BaseItemKind>();
|
||||
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)
|
||||
|
||||
@@ -113,6 +113,12 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public bool? CollapseBoxSetItems { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public BaseItemKind[] CollapseBoxSetItemTypes { get; set; } = [];
|
||||
|
||||
public string? NameStartsWithOrGreater { get; set; }
|
||||
|
||||
public string? NameStartsWith { get; set; }
|
||||
|
||||
@@ -30,6 +30,25 @@ public static class DescendantQueryHelper
|
||||
return descendants.AsQueryable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="context">Database context.</param>
|
||||
/// <param name="parentId">Parent item ID.</param>
|
||||
/// <returns>Queryable of owned descendant item IDs.</returns>
|
||||
public static IQueryable<Guid> GetOwnedDescendantIds(JellyfinDbContext context, Guid parentId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var descendants = TraverseHierarchyDownOwned(context, [parentId]);
|
||||
|
||||
descendants.Remove(parentId);
|
||||
|
||||
return descendants.AsQueryable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection<Guid> startIds)
|
||||
{
|
||||
var visited = new HashSet<Guid>(startIds);
|
||||
var folderStack = new HashSet<Guid>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses UP the hierarchy from items to find all ancestor folders.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user