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 the predicate identifying the items that stand on their own in a library. An alternate /// version is a second file for the item that links it rather than an item beside it, and an owned /// item belongs to its owner unless it is an extra (a trailer and the like, which carries both an /// owner and an extra type). Nothing here turns on who is asking, so a count that applies it /// answers the same with a user and without one. /// public static Expression> IsDistinctLibraryItem { get; } = b => !b.PrimaryVersionId.HasValue && (!b.OwnerId.HasValue || b.ExtraType != null); /// /// Builds the predicate identifying the items a user has played, counting a multi-version item as /// played when any of its alternate versions is. Mirrors the aggregation /// VersionResumeData.ApplyTo performs on the played flag a single item reports, so that a /// folder's unplayed count cannot disagree with the watched state its members render with. /// /// The id of the user whose played state to test. /// The predicate matching the items that user has played. public static Expression> IsPlayedBy(Guid userId) => b => b.UserData!.Any(u => u.UserId.Equals(userId) && u.Played) || b.LinkedChildEntities!.Any(lc => (lc.ChildType == LinkedChildType.LocalAlternateVersion || lc.ChildType == LinkedChildType.LinkedAlternateVersion) && lc.Child!.UserData!.Any(u => u.UserId.Equals(userId) && u.Played)); /// /// Builds the projection pairing an item's id with evaluated on that same /// row. A caller that needs the flag alongside the id composes it rather than testing membership of /// the played set: as a sub-select the set is unbounded by whatever the caller joins it to, so the /// database builds it from the whole table once per place it appears. /// /// The id of the user whose played state to test. /// The projection of each item onto its id and that user's played state. public static Expression> PlayedStateBy(Guid userId) { var played = IsPlayedBy(userId); var item = played.Parameters[0]; // Named members, as the compiler emits for an anonymous type: without them the query provider // cannot read a later `x.Id` back to the column it was built from and gives up translating. return Expression.Lambda>( Expression.New( typeof(LeafPlayedState).GetConstructor([typeof(Guid), typeof(bool)])!, [Expression.Property(item, nameof(BaseItemEntity.Id)), played.Body], [typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Id))!, typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Played))!]), item); } /// /// Builds the negation of , so a caller filtering for unplayed items reads /// the same definition of played as one filtering for played items. /// /// The id of the user whose played state to test. /// The predicate matching the items that user has not played. public static Expression> IsUnplayedBy(Guid userId) { var played = IsPlayedBy(userId); return Expression.Lambda>(Expression.Not(played.Body), played.Parameters); } /// /// 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); return AllDescendants(context, [parentId]) .Where(e => !e.Equals(parentId)) .Distinct(); } /// /// Gets all descendant IDs for multiple parent items in a single traversal. /// Traverses AncestorIds and LinkedChildren, like , but resolves /// the roots once for all seeds instead of once per seed. /// /// Database context. /// Parent item IDs. /// Set of all descendant item IDs (excluding the parent IDs themselves). public static HashSet GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList parentIds) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(parentIds); if (parentIds.Count == 0) { return []; } var descendants = AllDescendants(context, parentIds) .Distinct() .ToHashSet(); descendants.ExceptWith(parentIds); return descendants; } /// /// 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); // Both sides of a version group can hold a folder a caller would see as matching: the // alternate carries its own AncestorIds rows and may sit in a different library than the // primary it is reported against, and the primary is the item that becomes visible. var reportedItemIds = MatchingMediaOwnerIds(context, criteria) .Concat(GetPrimaryVersionIdsMatching(context, criteria)) .Distinct(); // One hop up the closure covers every ancestor level. var hierarchyAncestors = context.AncestorIds .Where(e => reportedItemIds.Contains(e.ItemId)) .Select(e => e.ParentItemId); var linkParents = ResolveLinkParents(context, reportedItemIds, hierarchyAncestors); // Read back as a sub-select so the result stays composable. Off the primary key, which is one // row per id: LinkedChildren would yield one row per link and lean on the outer Distinct. 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); // 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 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); // Anchored on the alternates rather than on the matches: "has a primary version" is served by // the partial PrimaryVersionId index, which holds only the few items that are second files, so // this costs a seek each into the stream index instead of a second pass over every stream row. var alternates = context.BaseItems.Where(v => v.PrimaryVersionId.HasValue); if (criteria is HasChapterImages) { return alternates .Where(v => context.Chapters.Any(c => c.ItemId.Equals(v.Id) && c.ImagePath != null)) .Select(v => v.PrimaryVersionId!.Value); } var matchingStreams = MatchingMediaStreams(context, criteria); return alternates .Where(v => matchingStreams.Any(ms => ms.ItemId.Equals(v.Id))) .Select(v => v.PrimaryVersionId!.Value); } // The ids of the items whose own media matches. Kept to the stream and chapter tables so their // covering indexes answer this outright: projecting the BaseItems navigation instead would add a // primary-key lookup per stream row rather than one per matching item, and the leading key of both // indexes leaves the ids already grouped, so the Distinct costs no sort. private static IQueryable MatchingMediaOwnerIds(JellyfinDbContext context, FolderMatchCriteria criteria) => criteria is HasChapterImages ? context.Chapters .Where(c => c.ImagePath != null) .Select(c => c.ItemId) .Distinct() : MatchingMediaStreams(context, criteria) .Select(ms => ms.ItemId) .Distinct(); // The stream rows a criteria matches. One definition, so the owner projection and the alternate // projection cannot drift apart despite reading it from opposite ends. private static IQueryable MatchingMediaStreams(JellyfinDbContext context, FolderMatchCriteria criteria) => criteria switch { HasSubtitles => context.MediaStreamInfos .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle), HasMediaStreamType m => GetMatchingMediaStreams(context, m), _ => 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 AllDescendants(JellyfinDbContext context, IReadOnlyList parentIds) { var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentIds); var linkedDescendants = context.LinkedChildren .WhereOneOrMany(linkRoots, e => e.ParentId) .Select(e => e.ChildId); return ClosureDescendants(context, closureRoots) .Concat(linkedDescendants); } 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, IReadOnlyList parentIds) { var visited = new HashSet(parentIds); var closureRoots = visited.ToList(); var linkRoots = visited.ToList(); var frontier = visited.ToList(); 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); } }