using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.MatchCriteria; namespace Jellyfin.Database.Implementations; /// /// Provides methods for querying item hierarchies. /// Uses AncestorIds and LinkedChildren tables for parent-child traversal. /// public static class DescendantQueryHelper { /// /// Gets the predicate identifying items that count toward played/total aggregation: /// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes). /// Shared by the per-item and batched count paths so they cannot diverge. /// public static Expression> IsCountableLeaf { get; } = b => !b.IsFolder && !b.IsVirtualItem; /// /// 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 (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId); var hierarchyDescendants = ClosureDescendants(context, closureRoots); var linkedDescendants = context.LinkedChildren .WhereOneOrMany(linkRoots, e => e.ParentId) .Select(e => e.ChildId); return hierarchyDescendants .Concat(linkedDescendants) .Where(e => !e.Equals(parentId)) .Distinct(); } /// /// 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); return ClosureDescendants(context, [parentId]) .Where(e => !e.Equals(parentId)) .Distinct(); } /// /// 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 descendants = ClosureDescendants(context, parentIds) .Distinct() .ToHashSet(); // The callers want only descendants, and an item is never its own descendant. descendants.ExceptWith(parentIds); 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), HasChapterImages => context.Chapters .Where(c => c.ImagePath != null) .Select(c => c.ItemId), HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m), _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") }; // One hop up the closure covers every ancestor level. var hierarchyAncestors = context.AncestorIds .Where(e => matchingItemIds.Contains(e.ItemId)) .Select(e => e.ParentItemId); var linkParents = ResolveLinkParents(context, matchingItemIds, hierarchyAncestors); // The link parents are resolved ids, so they are read back as a sub-select to keep the result // composable. An id without a BaseItem row could never match a caller's row anyway. var linkedParents = context.BaseItems .WhereOneOrMany(linkParents, e => e.Id) .Select(e => e.Id); var linkedParentAncestors = context.AncestorIds .WhereOneOrMany(linkParents, e => e.ItemId) .Select(e => e.ParentItemId); var seamAncestors = context.AncestorIds .Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId)) .Select(e => e.ParentItemId); return hierarchyAncestors .Concat(linkedParents) .Concat(linkedParentAncestors) .Concat(seamAncestors) .Distinct(); } private static IQueryable GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos .Where(ms => ms.StreamType == criteria.StreamType && (criteria.Language.Contains(ms.Language) || (criteria.Language.Contains("und") && string.IsNullOrEmpty(ms.Language)))); // und = undetermined if (criteria.IsExternal.HasValue) { var isExternal = criteria.IsExternal.Value; query = query.Where(ms => ms.IsExternal == isExternal); } return query.Select(ms => ms.ItemId); } private static IQueryable ClosureDescendants(JellyfinDbContext context, IReadOnlyList roots) { var direct = context.AncestorIds .WhereOneOrMany(roots, e => e.ParentItemId) .Select(e => e.ItemId); // An item carries its own chain plus its collection folders, never the UserRootFolder. var indirect = context.AncestorIds .Where(e => direct.Contains(e.ParentItemId)) .Select(e => e.ItemId); return direct.Concat(indirect); } /// /// Resolves every folder that reaches one of the matching items through a linked edge. /// /// The ids of the folders whose linked children lead, at any depth, to a matching item. private static List ResolveLinkParents(JellyfinDbContext context, IQueryable matchingItemIds, IQueryable ancestorsOfMatches) { // A link sits above the closure as well as above another link: a BoxSet holds a Series whose // episode matches, and another BoxSet holds that BoxSet. So the hop repeats until it stops // finding anything new, and each hop takes the links landing on the set itself or on a folder // that contains it. Only folders owning linked children are ever collected, which bounds this // by the number of BoxSets and Playlists rather than by the item count. var resolved = context.LinkedChildren .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId)) .Select(e => e.ParentId) .Distinct() .ToHashSet(); var frontier = resolved.ToList(); while (frontier.Count != 0) { var containingFolders = context.AncestorIds .WhereOneOrMany(frontier, e => e.ItemId) .Select(e => e.ParentItemId); var directLinkParents = context.LinkedChildren .WhereOneOrMany(frontier, e => e.ChildId) .Select(e => e.ParentId); var indirectLinkParents = context.LinkedChildren .Where(e => containingFolders.Contains(e.ChildId)) .Select(e => e.ParentId); var next = directLinkParents .Concat(indirectLinkParents) .Distinct() .ToArray(); frontier = []; foreach (var id in next) { // Cyclic links (a BoxSet holding itself, directly or not) terminate on the resolved set. if (resolved.Add(id)) { frontier.Add(id); } } } return [.. resolved]; } /// /// Resolves the roots the descendant sub-selects have to be anchored on. /// /// /// The roots whose AncestorIds closure belongs to the result, and the roots whose LinkedChildren /// belong to the result. /// private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId) { // A folder found through the closure needs no closure hop of its own. var closureRoots = new List { parentId }; var linkRoots = new List { parentId }; var visited = new HashSet { parentId }; var frontier = new List { parentId }; while (frontier.Count != 0) { var closureIds = ClosureDescendants(context, frontier); var linkedIds = context.LinkedChildren .WhereOneOrMany(frontier, e => e.ParentId) .Select(e => e.ChildId); // Folders that own linked children, i.e. the only items whose links are worth following. var linkOwners = context.BaseItems .Where(e => e.IsFolder && (closureIds.Contains(e.Id) || linkedIds.Contains(e.Id)) && context.LinkedChildren.Any(l => l.ParentId.Equals(e.Id))) .Select(e => e.Id) .ToArray(); var linkedFolders = context.BaseItems .Where(e => e.IsFolder && linkedIds.Contains(e.Id)) .Select(e => e.Id) .ToHashSet(); frontier = []; foreach (var id in linkOwners.Concat(linkedFolders)) { if (!visited.Add(id)) { continue; } frontier.Add(id); linkRoots.Add(id); // Only a folder reached through a link contributes a closure that is not covered by // the roots already collected. if (linkedFolders.Contains(id)) { closureRoots.Add(id); } } } return (closureRoots, linkRoots); } }