using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.MatchCriteria; namespace Jellyfin.Database.Implementations; /// /// Provides methods for querying item hierarchies using iterative traversal. /// Uses AncestorIds and LinkedChildren tables for parent-child traversal. /// public static class DescendantQueryHelper { /// /// Gets a queryable of all descendant IDs for a parent item. /// Traverses AncestorIds and LinkedChildren to find all descendants. /// /// Database context. /// Parent item ID. /// Queryable of descendant item IDs. public static IQueryable GetAllDescendantIds(JellyfinDbContext context, Guid parentId) { ArgumentNullException.ThrowIfNull(context); var descendants = TraverseHierarchyDown(context, [parentId]); descendants.Remove(parentId); 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 all owned descendant IDs for multiple parent items in a single traversal. /// More efficient than calling per parent because /// it performs one traversal for all seeds instead of N separate traversals. /// /// Database context. /// Parent item IDs. /// Set of all owned descendant item IDs (excluding the parent IDs themselves). public static HashSet GetOwnedDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList parentIds) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(parentIds); if (parentIds.Count == 0) { return []; } var seedSet = new HashSet(parentIds); var descendants = TraverseHierarchyDownOwned(context, seedSet); // Remove the seed IDs — callers want only descendants descendants.ExceptWith(seedSet); return descendants; } /// /// Gets a queryable of all folder IDs that have any descendant matching the specified criteria. /// Can be used in LINQ .Contains() expressions. /// /// Database context. /// The matching criteria to apply. /// Queryable of folder IDs. public static IQueryable GetFolderIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); var matchingItemIds = criteria switch { HasSubtitles => context.MediaStreamInfos .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle) .Select(ms => ms.ItemId) .Distinct() .ToHashSet(), HasChapterImages => context.Chapters .Where(c => c.ImagePath != null) .Select(c => c.ItemId) .Distinct() .ToHashSet(), HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m), _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") }; var ancestors = TraverseHierarchyUp(context, matchingItemIds); return ancestors.AsQueryable(); } private static HashSet GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos .Where(ms => ms.StreamType == criteria.StreamType && ms.Language == criteria.Language); if (criteria.IsExternal.HasValue) { var isExternal = criteria.IsExternal.Value; query = query.Where(ms => ms.IsExternal == isExternal); } return query.Select(ms => ms.ItemId).Distinct().ToHashSet(); } /// /// Traverses DOWN the hierarchy from parent folders to find all descendants. /// private static HashSet TraverseHierarchyDown(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(); var linkedChildren = context.LinkedChildren .WhereOneOrMany(currentFolders, e => e.ParentId) .Select(e => e.ChildId) .ToArray(); var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray(); if (allChildren.Length == 0) { break; } var childFolders = context.BaseItems .WhereOneOrMany(allChildren, e => e.Id) .Where(e => e.IsFolder) .Select(e => e.Id) .ToHashSet(); foreach (var childId in allChildren) { if (visited.Add(childId) && childFolders.Contains(childId)) { folderStack.Add(childId); } } } 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. /// private static HashSet TraverseHierarchyUp(JellyfinDbContext context, ICollection startIds) { var ancestors = new HashSet(); var itemStack = new HashSet(startIds); while (itemStack.Count != 0) { var currentItems = itemStack.ToArray(); itemStack.Clear(); var ancestorParents = context.AncestorIds .WhereOneOrMany(currentItems, e => e.ItemId) .Select(e => e.ParentItemId) .ToArray(); var linkedParents = context.LinkedChildren .WhereOneOrMany(currentItems, e => e.ChildId) .Select(e => e.ParentId) .ToArray(); foreach (var parentId in ancestorParents.Concat(linkedParents)) { if (ancestors.Add(parentId)) { itemStack.Add(parentId); } } } return ancestors; } }