< Summary - Jellyfin

Information
Class: Jellyfin.Database.Implementations.DescendantQueryHelper
Assembly: Jellyfin.Database.Implementations
File(s): /srv/git/jellyfin/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
Line coverage
97%
Covered lines: 162
Uncovered lines: 5
Coverable lines: 167
Total lines: 429
Line coverage: 97%
Branch coverage
85%
Covered branches: 24
Total branches: 28
Branch coverage: 85.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 17.4% (18/103) Branch coverage: 13.8% (5/36) Total lines: 2508/3/2026 - 12:16:46 AM Line coverage: 18.2% (19/104) Branch coverage: 13.8% (5/36) Total lines: 25910/29/2026 - 12:17:11 AM Line coverage: 97% (162/167) Branch coverage: 85.7% (24/28) Total lines: 429 5/16/2026 - 12:15:55 AM Line coverage: 17.4% (18/103) Branch coverage: 13.8% (5/36) Total lines: 2508/3/2026 - 12:16:46 AM Line coverage: 18.2% (19/104) Branch coverage: 13.8% (5/36) Total lines: 25910/29/2026 - 12:17:11 AM Line coverage: 97% (162/167) Branch coverage: 85.7% (24/28) Total lines: 429

Coverage delta

Coverage delta 79 -79

Metrics

File(s)

/srv/git/jellyfin/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Linq.Expressions;
 5using Jellyfin.Database.Implementations.Entities;
 6using Jellyfin.Database.Implementations.MatchCriteria;
 7
 8namespace Jellyfin.Database.Implementations;
 9
 10/// <summary>
 11/// Provides methods for querying item hierarchies.
 12/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
 13/// </summary>
 14public static class DescendantQueryHelper
 15{
 16    /// <summary>
 17    /// Gets the predicate identifying items that count toward played/total aggregation:
 18    /// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes).
 19    /// Shared by the per-item and batched count paths so they cannot diverge.
 20    /// </summary>
 21    public static Expression<Func<BaseItemEntity, bool>> IsCountableLeaf { get; } =
 222        b => !b.IsFolder && !b.IsVirtualItem;
 23
 24    /// <summary>
 25    /// Gets the predicate identifying the items that stand on their own in a library. An alternate
 26    /// version is a second file for the item that links it rather than an item beside it, and an owned
 27    /// item belongs to its owner unless it is an extra (a trailer and the like, which carries both an
 28    /// owner and an extra type). Nothing here turns on who is asking, so a count that applies it
 29    /// answers the same with a user and without one.
 30    /// </summary>
 31    public static Expression<Func<BaseItemEntity, bool>> IsDistinctLibraryItem { get; } =
 232        b => !b.PrimaryVersionId.HasValue && (!b.OwnerId.HasValue || b.ExtraType != null);
 33
 34    /// <summary>
 35    /// Builds the predicate identifying the items a user has played, counting a multi-version item as
 36    /// played when any of its alternate versions is. Mirrors the aggregation
 37    /// <c>VersionResumeData.ApplyTo</c> performs on the played flag a single item reports, so that a
 38    /// folder's unplayed count cannot disagree with the watched state its members render with.
 39    /// </summary>
 40    /// <param name="userId">The id of the user whose played state to test.</param>
 41    /// <returns>The predicate matching the items that user has played.</returns>
 42    public static Expression<Func<BaseItemEntity, bool>> IsPlayedBy(Guid userId) =>
 1643        b => b.UserData!.Any(u => u.UserId.Equals(userId) && u.Played)
 1644            || b.LinkedChildEntities!.Any(lc =>
 1645                (lc.ChildType == LinkedChildType.LocalAlternateVersion || lc.ChildType == LinkedChildType.LinkedAlternat
 1646                && lc.Child!.UserData!.Any(u => u.UserId.Equals(userId) && u.Played));
 47
 48    /// <summary>
 49    /// Builds the projection pairing an item's id with <see cref="IsPlayedBy"/> evaluated on that same
 50    /// row. A caller that needs the flag alongside the id composes it rather than testing membership of
 51    /// the played set: as a sub-select the set is unbounded by whatever the caller joins it to, so the
 52    /// database builds it from the whole table once per place it appears.
 53    /// </summary>
 54    /// <param name="userId">The id of the user whose played state to test.</param>
 55    /// <returns>The projection of each item onto its id and that user's played state.</returns>
 56    public static Expression<Func<BaseItemEntity, LeafPlayedState>> PlayedStateBy(Guid userId)
 57    {
 358        var played = IsPlayedBy(userId);
 359        var item = played.Parameters[0];
 60
 61        // Named members, as the compiler emits for an anonymous type: without them the query provider
 62        // cannot read a later `x.Id` back to the column it was built from and gives up translating.
 363        return Expression.Lambda<Func<BaseItemEntity, LeafPlayedState>>(
 364            Expression.New(
 365                typeof(LeafPlayedState).GetConstructor([typeof(Guid), typeof(bool)])!,
 366                [Expression.Property(item, nameof(BaseItemEntity.Id)), played.Body],
 367                [typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Id))!, typeof(LeafPlayedState).GetProperty(n
 368            item);
 69    }
 70
 71    /// <summary>
 72    /// Builds the negation of <see cref="IsPlayedBy"/>, so a caller filtering for unplayed items reads
 73    /// the same definition of played as one filtering for played items.
 74    /// </summary>
 75    /// <param name="userId">The id of the user whose played state to test.</param>
 76    /// <returns>The predicate matching the items that user has not played.</returns>
 77    public static Expression<Func<BaseItemEntity, bool>> IsUnplayedBy(Guid userId)
 78    {
 179        var played = IsPlayedBy(userId);
 180        return Expression.Lambda<Func<BaseItemEntity, bool>>(Expression.Not(played.Body), played.Parameters);
 81    }
 82
 83    /// <summary>
 84    /// Gets a queryable of all descendant IDs for a parent item.
 85    /// Traverses AncestorIds and LinkedChildren to find all descendants.
 86    /// </summary>
 87    /// <param name="context">Database context.</param>
 88    /// <param name="parentId">Parent item ID.</param>
 89    /// <returns>Queryable of descendant item IDs.</returns>
 90    public static IQueryable<Guid> GetAllDescendantIds(JellyfinDbContext context, Guid parentId)
 91    {
 1592        ArgumentNullException.ThrowIfNull(context);
 93
 1594        return AllDescendants(context, [parentId])
 1595            .Where(e => !e.Equals(parentId))
 1596            .Distinct();
 97    }
 98
 99    /// <summary>
 100    /// Gets all descendant IDs for multiple parent items in a single traversal.
 101    /// Traverses AncestorIds and LinkedChildren, like <see cref="GetAllDescendantIds"/>, but resolves
 102    /// the roots once for all seeds instead of once per seed.
 103    /// </summary>
 104    /// <param name="context">Database context.</param>
 105    /// <param name="parentIds">Parent item IDs.</param>
 106    /// <returns>Set of all descendant item IDs (excluding the parent IDs themselves).</returns>
 107    public static HashSet<Guid> GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
 108    {
 14109        ArgumentNullException.ThrowIfNull(context);
 14110        ArgumentNullException.ThrowIfNull(parentIds);
 111
 14112        if (parentIds.Count == 0)
 113        {
 0114            return [];
 115        }
 116
 14117        var descendants = AllDescendants(context, parentIds)
 14118            .Distinct()
 14119            .ToHashSet();
 120
 14121        descendants.ExceptWith(parentIds);
 122
 14123        return descendants;
 124    }
 125
 126    /// <summary>
 127    /// Gets a queryable of all owned descendant IDs for a parent item.
 128    /// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations).
 129    /// Use this for deletion to avoid destroying items that are merely linked (e.g. movies in a BoxSet).
 130    /// </summary>
 131    /// <param name="context">Database context.</param>
 132    /// <param name="parentId">Parent item ID.</param>
 133    /// <returns>Queryable of owned descendant item IDs.</returns>
 134    public static IQueryable<Guid> GetOwnedDescendantIds(JellyfinDbContext context, Guid parentId)
 135    {
 2136        ArgumentNullException.ThrowIfNull(context);
 137
 2138        return ClosureDescendants(context, [parentId])
 2139            .Where(e => !e.Equals(parentId))
 2140            .Distinct();
 141    }
 142
 143    /// <summary>
 144    /// Gets all owned descendant IDs for multiple parent items in a single traversal.
 145    /// More efficient than calling <see cref="GetOwnedDescendantIds"/> per parent because
 146    /// it performs one traversal for all seeds instead of N separate traversals.
 147    /// </summary>
 148    /// <param name="context">Database context.</param>
 149    /// <param name="parentIds">Parent item IDs.</param>
 150    /// <returns>Set of all owned descendant item IDs (excluding the parent IDs themselves).</returns>
 151    public static HashSet<Guid> GetOwnedDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
 152    {
 14153        ArgumentNullException.ThrowIfNull(context);
 14154        ArgumentNullException.ThrowIfNull(parentIds);
 155
 14156        if (parentIds.Count == 0)
 157        {
 0158            return [];
 159        }
 160
 14161        var descendants = ClosureDescendants(context, parentIds)
 14162            .Distinct()
 14163            .ToHashSet();
 164
 14165        descendants.ExceptWith(parentIds);
 166
 14167        return descendants;
 168    }
 169
 170    /// <summary>
 171    /// Gets a queryable of all folder IDs that have any descendant matching the specified criteria.
 172    /// Can be used in LINQ .Contains() expressions.
 173    /// </summary>
 174    /// <param name="context">Database context.</param>
 175    /// <param name="criteria">The matching criteria to apply.</param>
 176    /// <returns>Queryable of folder IDs.</returns>
 177    public static IQueryable<Guid> GetFolderIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria)
 178    {
 24179        ArgumentNullException.ThrowIfNull(context);
 24180        ArgumentNullException.ThrowIfNull(criteria);
 181
 182        // Both sides of a version group can hold a folder a caller would see as matching: the
 183        // alternate carries its own AncestorIds rows and may sit in a different library than the
 184        // primary it is reported against, and the primary is the item that becomes visible.
 24185        var reportedItemIds = MatchingMediaOwnerIds(context, criteria)
 24186            .Concat(GetPrimaryVersionIdsMatching(context, criteria))
 24187            .Distinct();
 188
 189        // One hop up the closure covers every ancestor level.
 24190        var hierarchyAncestors = context.AncestorIds
 24191            .Where(e => reportedItemIds.Contains(e.ItemId))
 24192            .Select(e => e.ParentItemId);
 193
 24194        var linkParents = ResolveLinkParents(context, reportedItemIds, hierarchyAncestors);
 195
 196        // Read back as a sub-select so the result stays composable. Off the primary key, which is one
 197        // row per id: LinkedChildren would yield one row per link and lean on the outer Distinct.
 24198        var linkedParents = context.BaseItems
 24199            .WhereOneOrMany(linkParents, e => e.Id)
 24200            .Select(e => e.Id);
 201
 24202        var linkedParentAncestors = context.AncestorIds
 24203            .WhereOneOrMany(linkParents, e => e.ItemId)
 24204            .Select(e => e.ParentItemId);
 205
 206        // The chain an item carries stops at its collection folders, so this hop crosses that seam to
 207        // the UserRootFolder above them. One statement for both sides beats a sub-select per side.
 24208        var seamAncestors = context.AncestorIds
 24209            .Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId))
 24210            .Select(e => e.ParentItemId);
 211
 24212        return hierarchyAncestors
 24213            .Concat(linkedParents)
 24214            .Concat(linkedParentAncestors)
 24215            .Concat(seamAncestors)
 24216            .Distinct();
 217    }
 218
 219    /// <summary>
 220    /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the
 221    /// criteria.
 222    /// </summary>
 223    /// <param name="context">Database context.</param>
 224    /// <param name="criteria">The matching criteria to apply.</param>
 225    /// <returns>Queryable of primary version item IDs.</returns>
 226    /// <remarks>
 227    /// For callers that already test an item's own media with their own indexed predicate: this covers
 228    /// exactly what such a predicate misses, and the filtered PrimaryVersionId index keeps it to the few
 229    /// items that have versions at all.
 230    /// </remarks>
 231    public static IQueryable<Guid> GetPrimaryVersionIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria)
 232    {
 42233        ArgumentNullException.ThrowIfNull(context);
 42234        ArgumentNullException.ThrowIfNull(criteria);
 235
 236        // Anchored on the alternates rather than on the matches: "has a primary version" is served by
 237        // the partial PrimaryVersionId index, which holds only the few items that are second files, so
 238        // this costs a seek each into the stream index instead of a second pass over every stream row.
 42239        var alternates = context.BaseItems.Where(v => v.PrimaryVersionId.HasValue);
 240
 42241        if (criteria is HasChapterImages)
 242        {
 4243            return alternates
 4244                .Where(v => context.Chapters.Any(c => c.ItemId.Equals(v.Id) && c.ImagePath != null))
 4245                .Select(v => v.PrimaryVersionId!.Value);
 246        }
 247
 38248        var matchingStreams = MatchingMediaStreams(context, criteria);
 249
 38250        return alternates
 38251            .Where(v => matchingStreams.Any(ms => ms.ItemId.Equals(v.Id)))
 38252            .Select(v => v.PrimaryVersionId!.Value);
 253    }
 254
 255    // The ids of the items whose own media matches. Kept to the stream and chapter tables so their
 256    // covering indexes answer this outright: projecting the BaseItems navigation instead would add a
 257    // primary-key lookup per stream row rather than one per matching item, and the leading key of both
 258    // indexes leaves the ids already grouped, so the Distinct costs no sort.
 259    private static IQueryable<Guid> MatchingMediaOwnerIds(JellyfinDbContext context, FolderMatchCriteria criteria)
 24260        => criteria is HasChapterImages
 24261            ? context.Chapters
 24262                .Where(c => c.ImagePath != null)
 24263                .Select(c => c.ItemId)
 24264                .Distinct()
 24265            : MatchingMediaStreams(context, criteria)
 24266                .Select(ms => ms.ItemId)
 24267                .Distinct();
 268
 269    // The stream rows a criteria matches. One definition, so the owner projection and the alternate
 270    // projection cannot drift apart despite reading it from opposite ends.
 271    private static IQueryable<MediaStreamInfo> MatchingMediaStreams(JellyfinDbContext context, FolderMatchCriteria crite
 60272        => criteria switch
 60273        {
 26274            HasSubtitles => context.MediaStreamInfos
 26275                .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle),
 34276            HasMediaStreamType m => GetMatchingMediaStreams(context, m),
 0277            _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Na
 60278        };
 279
 280    private static IQueryable<MediaStreamInfo> GetMatchingMediaStreams(JellyfinDbContext context, HasMediaStreamType cri
 281    {
 34282        var query = context.MediaStreamInfos
 34283            .Where(ms => ms.StreamType == criteria.StreamType
 34284                   && (criteria.Language.Contains(ms.Language)
 34285                       || (criteria.Language.Contains("und") && string.IsNullOrEmpty(ms.Language)))); // und = undetermi
 286
 34287        if (criteria.IsExternal.HasValue)
 288        {
 0289            var isExternal = criteria.IsExternal.Value;
 0290            query = query.Where(ms => ms.IsExternal == isExternal);
 291        }
 292
 34293        return query;
 294    }
 295
 296    private static IQueryable<Guid> AllDescendants(JellyfinDbContext context, IReadOnlyList<Guid> parentIds)
 297    {
 29298        var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentIds);
 299
 29300        var linkedDescendants = context.LinkedChildren
 29301            .WhereOneOrMany(linkRoots, e => e.ParentId)
 29302            .Select(e => e.ChildId);
 303
 29304        return ClosureDescendants(context, closureRoots)
 29305            .Concat(linkedDescendants);
 306    }
 307
 308    private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots)
 309    {
 80310        var direct = context.AncestorIds
 80311            .WhereOneOrMany(roots, e => e.ParentItemId)
 80312            .Select(e => e.ItemId);
 313
 314        // An item carries its own chain plus its collection folders, never the UserRootFolder.
 80315        var indirect = context.AncestorIds
 80316            .Where(e => direct.Contains(e.ParentItemId))
 80317            .Select(e => e.ItemId);
 318
 80319        return direct.Concat(indirect);
 320    }
 321
 322    // Resolves the folders whose linked children lead, at any depth, to a matching item.
 323    private static List<Guid> ResolveLinkParents(JellyfinDbContext context, IQueryable<Guid> matchingItemIds, IQueryable
 324    {
 325        // An alternate version is a second file for the item that links it, not a child of it, so that
 326        // edge is not walked. It is also the one link a non-folder owns, and there is one per remuxed
 327        // movie: walking it would swell this list from the BoxSet and Playlist count to the item count,
 328        // and the list is bound into every statement the returned queryable is embedded in.
 24329        var containerLinks = context.LinkedChildren
 24330            .Where(e => e.ChildType != LinkedChildType.LocalAlternateVersion
 24331                && e.ChildType != LinkedChildType.LinkedAlternateVersion);
 332
 333        // A link sits above the closure and above another link alike, so the hop repeats until nothing
 334        // new turns up.
 24335        var resolved = containerLinks
 24336            .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId))
 24337            .Select(e => e.ParentId)
 24338            .Distinct()
 24339            .ToHashSet();
 340
 24341        var frontier = resolved.ToList();
 342
 38343        while (frontier.Count != 0)
 344        {
 14345            var containingFolders = context.AncestorIds
 14346                .WhereOneOrMany(frontier, e => e.ItemId)
 14347                .Select(e => e.ParentItemId);
 348
 14349            var directLinkParents = containerLinks
 14350                .WhereOneOrMany(frontier, e => e.ChildId)
 14351                .Select(e => e.ParentId);
 352
 14353            var indirectLinkParents = containerLinks
 14354                .Where(e => containingFolders.Contains(e.ChildId))
 14355                .Select(e => e.ParentId);
 356
 14357            var next = directLinkParents
 14358                .Concat(indirectLinkParents)
 14359                .Distinct()
 14360                .ToArray();
 361
 14362            frontier = [];
 32363            foreach (var id in next)
 364            {
 365                // Cyclic links terminate on the resolved set.
 2366                if (resolved.Add(id))
 367                {
 1368                    frontier.Add(id);
 369                }
 370            }
 371        }
 372
 24373        return [.. resolved];
 374    }
 375
 376    // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure,
 377    // and those contributing their linked children.
 378    private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, IReadOn
 379    {
 29380        var visited = new HashSet<Guid>(parentIds);
 29381        var closureRoots = visited.ToList();
 29382        var linkRoots = visited.ToList();
 29383        var frontier = visited.ToList();
 384
 64385        while (frontier.Count != 0)
 386        {
 35387            var closureIds = ClosureDescendants(context, frontier);
 388
 35389            var linkedIds = context.LinkedChildren
 35390                .WhereOneOrMany(frontier, e => e.ParentId)
 35391                .Select(e => e.ChildId);
 392
 35393            var linkedFolders = context.BaseItems
 35394                .Where(e => e.IsFolder && linkedIds.Contains(e.Id))
 35395                .Select(e => e.Id)
 35396                .ToHashSet();
 397
 398            // Folders whose own links have to be followed. Driven off LinkedChildren because owning a
 399            // link is the rare property, so the folder check only reaches rows that can qualify. That
 400            // check stays: a non-folder owns links too (a movie and its alternate versions).
 35401            var linkOwners = context.LinkedChildren
 35402                .Where(e => (closureIds.Contains(e.ParentId) || linkedIds.Contains(e.ParentId))
 35403                    && e.Parent!.IsFolder)
 35404                .Select(e => e.ParentId)
 35405                .Distinct()
 35406                .ToArray();
 407
 35408            frontier = [];
 88409            foreach (var id in linkOwners.Concat(linkedFolders))
 410            {
 9411                if (!visited.Add(id))
 412                {
 413                    continue;
 414                }
 415
 6416                frontier.Add(id);
 6417                linkRoots.Add(id);
 418
 419                // Only a folder reached through a link adds a closure the roots so far do not cover.
 6420                if (linkedFolders.Contains(id))
 421                {
 5422                    closureRoots.Add(id);
 423                }
 424            }
 425        }
 426
 29427        return (closureRoots, linkRoots);
 428    }
 429}

Methods/Properties

.cctor()
IsPlayedBy(System.Guid)
PlayedStateBy(System.Guid)
IsUnplayedBy(System.Guid)
GetAllDescendantIds(Jellyfin.Database.Implementations.JellyfinDbContext,System.Guid)
GetAllDescendantIdsBatch(Jellyfin.Database.Implementations.JellyfinDbContext,System.Collections.Generic.IReadOnlyList`1<System.Guid>)
GetOwnedDescendantIds(Jellyfin.Database.Implementations.JellyfinDbContext,System.Guid)
GetOwnedDescendantIdsBatch(Jellyfin.Database.Implementations.JellyfinDbContext,System.Collections.Generic.IReadOnlyList`1<System.Guid>)
GetFolderIdsMatching(Jellyfin.Database.Implementations.JellyfinDbContext,Jellyfin.Database.Implementations.MatchCriteria.FolderMatchCriteria)
GetPrimaryVersionIdsMatching(Jellyfin.Database.Implementations.JellyfinDbContext,Jellyfin.Database.Implementations.MatchCriteria.FolderMatchCriteria)
MatchingMediaOwnerIds(Jellyfin.Database.Implementations.JellyfinDbContext,Jellyfin.Database.Implementations.MatchCriteria.FolderMatchCriteria)
MatchingMediaStreams(Jellyfin.Database.Implementations.JellyfinDbContext,Jellyfin.Database.Implementations.MatchCriteria.FolderMatchCriteria)
GetMatchingMediaStreams(Jellyfin.Database.Implementations.JellyfinDbContext,Jellyfin.Database.Implementations.MatchCriteria.HasMediaStreamType)
AllDescendants(Jellyfin.Database.Implementations.JellyfinDbContext,System.Collections.Generic.IReadOnlyList`1<System.Guid>)
ClosureDescendants(Jellyfin.Database.Implementations.JellyfinDbContext,System.Collections.Generic.IReadOnlyList`1<System.Guid>)
ResolveLinkParents(Jellyfin.Database.Implementations.JellyfinDbContext,System.Linq.IQueryable`1<System.Guid>,System.Linq.IQueryable`1<System.Guid>)
ResolveLinkedRoots(Jellyfin.Database.Implementations.JellyfinDbContext,System.Collections.Generic.IReadOnlyList`1<System.Guid>)