Add folder-aware filter extensions and descendant query provider

- Add FolderAwareFilterExtensions for LinkedChildren-based filtering
- Add IDescendantQueryProvider interface for database-specific queries
- Add MatchCriteria classes for folder filtering
- Add SqliteDescendantQueryProvider implementation
This commit is contained in:
Shadowghost
2026-01-17 15:58:00 +01:00
parent f260585917
commit 912a963a2b
7 changed files with 256 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations.MatchCriteria;
namespace Jellyfin.Database.Implementations;
/// <summary>
/// Provider interface for descendant queries using recursive CTEs.
/// Each database provider implements this with provider-specific SQL.
/// </summary>
public interface IDescendantQueryProvider
{
/// <summary>
/// Gets a queryable of all descendant IDs for a parent item.
/// Uses recursive CTE to traverse AncestorIds and LinkedChildren infinitely.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="parentId">Parent item ID.</param>
/// <returns>Queryable of descendant item IDs.</returns>
IQueryable<Guid> GetAllDescendantIds(JellyfinDbContext context, Guid parentId);
/// <summary>
/// Gets a queryable of all folder IDs that have any descendant matching the specified criteria.
/// Uses recursive CTE for infinite depth traversal. Can be used in LINQ .Contains() expressions.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="criteria">The matching criteria to apply.</param>
/// <returns>Queryable of folder IDs.</returns>
IQueryable<Guid> GetFolderIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria);
}

View File

@@ -0,0 +1,6 @@
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Base type for folder matching criteria using discriminated union pattern.
/// </summary>
public abstract record FolderMatchCriteria;

View File

@@ -0,0 +1,6 @@
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Matches folders containing descendants with chapter images.
/// </summary>
public sealed record HasChapterImages : FolderMatchCriteria;

View File

@@ -0,0 +1,14 @@
using Jellyfin.Database.Implementations.Entities;
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Matches folders containing descendants with a specific media stream type and language.
/// </summary>
/// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param>
/// <param name="Language">The language to match.</param>
/// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param>
public sealed record HasMediaStreamType(
MediaStreamTypeEntity StreamType,
string Language,
bool? IsExternal = null) : FolderMatchCriteria;

View File

@@ -0,0 +1,6 @@
namespace Jellyfin.Database.Implementations.MatchCriteria;
/// <summary>
/// Matches folders containing descendants with subtitles.
/// </summary>
public sealed record HasSubtitles : FolderMatchCriteria;

View File

@@ -0,0 +1,129 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.MatchCriteria;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Database.Providers.Sqlite;
/// <summary>
/// SQLite implementation of descendant queries using optimized ancestor lookups.
/// Uses AncestorIds and LinkedChildren tables for efficient parent-child traversal.
/// </summary>
public class SqliteDescendantQueryProvider : IDescendantQueryProvider
{
/// <summary>
/// Recursive CTE fragment that traverses UP the tree from matching items to find all ancestor folders.
/// Expects a preceding CTE named "MatchingItems" with an ItemId column.
/// </summary>
private const string AllAncestorsCte = """
AllAncestors AS (
SELECT a.ParentItemId AS AncestorId
FROM AncestorIds a
WHERE a.ItemId IN (SELECT ItemId FROM MatchingItems)
UNION
SELECT lc.ParentId AS AncestorId
FROM LinkedChildren lc
WHERE lc.ChildId IN (SELECT ItemId FROM MatchingItems)
UNION
SELECT a.ParentItemId AS AncestorId
FROM AllAncestors aa
INNER JOIN AncestorIds a ON a.ItemId = aa.AncestorId
UNION
SELECT lc.ParentId AS AncestorId
FROM AllAncestors aa
INNER JOIN LinkedChildren lc ON lc.ChildId = aa.AncestorId
)
SELECT DISTINCT AncestorId AS Value FROM AllAncestors
""";
/// <inheritdoc />
public IQueryable<Guid> GetAllDescendantIds(JellyfinDbContext context, Guid parentId)
{
ArgumentNullException.ThrowIfNull(context);
var sql = """
WITH RECURSIVE AllDescendants AS (
SELECT ItemId FROM AncestorIds WHERE ParentItemId = {0}
UNION
SELECT ChildId AS ItemId FROM LinkedChildren WHERE ParentId = {0}
UNION ALL
SELECT a.ItemId
FROM AllDescendants d
INNER JOIN BaseItems b ON b.Id = d.ItemId AND b.IsFolder = 1
INNER JOIN AncestorIds a ON a.ParentItemId = d.ItemId
UNION ALL
SELECT lc.ChildId AS ItemId
FROM AllDescendants d
INNER JOIN BaseItems b ON b.Id = d.ItemId AND b.IsFolder = 1
INNER JOIN LinkedChildren lc ON lc.ParentId = d.ItemId
)
SELECT DISTINCT ItemId AS Value FROM AllDescendants
""";
return context.Database.SqlQueryRaw<Guid>(sql, parentId);
}
/// <inheritdoc />
public IQueryable<Guid> GetFolderIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(criteria);
return criteria switch
{
HasSubtitles => GetFolderIdsWithSubtitles(context),
HasChapterImages => GetFolderIdsWithChapterImages(context),
HasMediaStreamType m => GetFolderIdsWithMediaStream(context, m.StreamType, m.Language, m.IsExternal),
_ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}")
};
}
private IQueryable<Guid> GetFolderIdsWithSubtitles(JellyfinDbContext context)
{
var sql = $"""
WITH RECURSIVE MatchingItems AS (
SELECT DISTINCT ms.ItemId FROM MediaStreamInfos ms WHERE ms.StreamType = 2
),
{AllAncestorsCte}
""";
return context.Database.SqlQueryRaw<Guid>(sql);
}
private IQueryable<Guid> GetFolderIdsWithChapterImages(JellyfinDbContext context)
{
var sql = $"""
WITH RECURSIVE MatchingItems AS (
SELECT DISTINCT c.ItemId FROM Chapters c WHERE c.ImagePath IS NOT NULL
),
{AllAncestorsCte}
""";
return context.Database.SqlQueryRaw<Guid>(sql);
}
private IQueryable<Guid> GetFolderIdsWithMediaStream(JellyfinDbContext context, MediaStreamTypeEntity streamType, string language, bool? isExternal)
{
ArgumentNullException.ThrowIfNull(language);
var streamTypeInt = (int)streamType;
var externalCondition = isExternal switch
{
true => " AND ms.IsExternal = 1",
false => " AND ms.IsExternal = 0",
null => string.Empty
};
var sql = $$"""
WITH RECURSIVE MatchingItems AS (
SELECT DISTINCT ms.ItemId FROM MediaStreamInfos ms
WHERE ms.StreamType = {0} AND ms.Language = {1}{{externalCondition}}
),
{{AllAncestorsCte}}
""";
return context.Database.SqlQueryRaw<Guid>(sql, streamTypeInt, language);
}
}