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);
}
}