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(); 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 = GetItemIdsMatching(context, criteria); // 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); // Read back as a sub-select so the result stays composable. LinkedChildren is the cheapest // source: owning a link is what put an id in the set, and ParentId is its leading key. var linkedParents = context.LinkedChildren .WhereOneOrMany(linkParents, e => e.ParentId) .Select(e => e.ParentId); var linkedParentAncestors = context.AncestorIds .WhereOneOrMany(linkParents, e => e.ItemId) .Select(e => e.ParentItemId); // The chain an item carries stops at its collection folders, so this hop crosses that seam to // the UserRootFolder above them. One statement for both sides beats a sub-select per side. 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(); } /// /// Gets a queryable of the IDs of the items whose media matches the criteria. /// /// Database context. /// The matching criteria to apply. /// Queryable of item IDs. /// /// An alternate version is a second file for its primary version and is never listed on its own, so a /// track only that file carries is reported against the primary: the item a caller can actually see. /// public static IQueryable GetItemIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); return MatchingMediaOwners(context, criteria) .Select(e => e.PrimaryVersionId ?? e.Id); } /// /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the /// criteria. /// /// Database context. /// The matching criteria to apply. /// Queryable of primary version item IDs. /// /// For callers that already test an item's own media with their own indexed predicate: this covers /// exactly what such a predicate misses, and the filtered PrimaryVersionId index keeps it to the few /// items that have versions at all. /// public static IQueryable GetPrimaryVersionIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); return MatchingMediaOwners(context, criteria) .Where(e => e.PrimaryVersionId.HasValue) .Select(e => e.PrimaryVersionId!.Value); } // The items whose own media matches, as their BaseItems rows so the version group can be read off // them. One definition of "matches" per criteria, so the projections above cannot drift apart. private static IQueryable MatchingMediaOwners(JellyfinDbContext context, FolderMatchCriteria criteria) => criteria switch { HasSubtitles => context.MediaStreamInfos .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle) .Select(ms => ms.Item), HasChapterImages => context.Chapters .Where(c => c.ImagePath != null) .Select(c => c.Item), HasMediaStreamType m => GetMatchingMediaStreams(context, m).Select(ms => ms.Item), _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") }; private static IQueryable GetMatchingMediaStreams(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; } 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 the folders whose linked children lead, at any depth, to a matching item. private static List ResolveLinkParents(JellyfinDbContext context, IQueryable matchingItemIds, IQueryable ancestorsOfMatches) { // An alternate version is a second file for the item that links it, not a child of it, so that // edge is not walked. It is also the one link a non-folder owns, and there is one per remuxed // movie: walking it would swell this list from the BoxSet and Playlist count to the item count, // and the list is bound into every statement the returned queryable is embedded in. var containerLinks = context.LinkedChildren .Where(e => e.ChildType != LinkedChildType.LocalAlternateVersion && e.ChildType != LinkedChildType.LinkedAlternateVersion); // A link sits above the closure and above another link alike, so the hop repeats until nothing // new turns up. var resolved = containerLinks .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 = containerLinks .WhereOneOrMany(frontier, e => e.ChildId) .Select(e => e.ParentId); var indirectLinkParents = containerLinks .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 terminate on the resolved set. if (resolved.Add(id)) { frontier.Add(id); } } } return [.. resolved]; } // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure, // and those contributing their linked children. private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId) { 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); var linkedFolders = context.BaseItems .Where(e => e.IsFolder && linkedIds.Contains(e.Id)) .Select(e => e.Id) .ToHashSet(); // Folders whose own links have to be followed. Driven off LinkedChildren because owning a // link is the rare property, so the folder check only reaches rows that can qualify. That // check stays: a non-folder owns links too (a movie and its alternate versions). var linkOwners = context.LinkedChildren .Where(e => (closureIds.Contains(e.ParentId) || linkedIds.Contains(e.ParentId)) && e.Parent!.IsFolder) .Select(e => e.ParentId) .Distinct() .ToArray(); 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 adds a closure the roots so far do not cover. if (linkedFolders.Contains(id)) { closureRoots.Add(id); } } } return (closureRoots, linkRoots); } }