Merge pull request #17607 from Shadowghost/optimize-db-helper-memory
Optimize query helper memory
This commit is contained in:
@@ -35,6 +35,18 @@ public sealed partial class BaseItemRepository
|
||||
// instance across several lambdas, and this filter is combined into a tree more than once.
|
||||
private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
|
||||
|
||||
// "und" is the language filters' stand-in for a track that declares no language at all.
|
||||
private static string NormalizeLanguage(string language)
|
||||
=> string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
|
||||
|
||||
// The primary versions whose alternate version satisfies a dimension bound. Anchored on
|
||||
// PrimaryVersionId so the filtered index carries it rather than a scan of every item.
|
||||
private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound)
|
||||
=> context.BaseItems
|
||||
.Where(v => v.PrimaryVersionId != null)
|
||||
.Where(bound)
|
||||
.Select(v => v.PrimaryVersionId!.Value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IQueryable<BaseItemEntity> TranslateQuery(
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
@@ -70,47 +82,86 @@ public sealed partial class BaseItemRepository
|
||||
include4K = true;
|
||||
}
|
||||
|
||||
// Non-folders: check own resolution directly (no subquery).
|
||||
// Folders (Series, BoxSets): EXISTS check on descendants/linked children.
|
||||
// Using navigation properties (a.Item, lc.Child) produces efficient
|
||||
// EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
|
||||
// A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters
|
||||
// on is the best any of the item's versions offers, not just the primary file's. Three sets,
|
||||
// because a bucket is as much about what the version group does not have as what it does, and
|
||||
// because an unprobed primary can still be placed by a version that does carry dimensions.
|
||||
// The filtered PrimaryVersionId index keeps all three to the few items that have versions.
|
||||
var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth);
|
||||
var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth);
|
||||
var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight);
|
||||
|
||||
// Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row
|
||||
// cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself
|
||||
// 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather
|
||||
// than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed
|
||||
// exactly as a top-level item is so that the two cannot disagree; the navigation properties
|
||||
// (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...).
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
(!e.IsFolder && e.Width > 0
|
||||
&& ((includeSD && e.Width < HDWidth)
|
||||
|| (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
|
||||
|| (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
|
||||
(!e.IsFolder
|
||||
&& ((includeSD
|
||||
&& ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id))
|
||||
&& !versionsHd.Contains(e.Id)
|
||||
&& !versions4K.Contains(e.Id))
|
||||
|| (includeHD
|
||||
&& (e.Width >= HDWidth || versionsHd.Contains(e.Id))
|
||||
&& !(e.Width >= UHDWidth || e.Height >= UHDHeight)
|
||||
&& !versions4K.Contains(e.Id))
|
||||
|| (include4K
|
||||
&& (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id)))))
|
||||
|| (e.IsFolder
|
||||
&& (e.Children!.Any(a =>
|
||||
a.Item.Width > 0
|
||||
&& ((includeSD && a.Item.Width < HDWidth)
|
||||
|| (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
|
||||
|| (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
|
||||
(includeSD
|
||||
&& ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId))
|
||||
&& !versionsHd.Contains(a.ItemId)
|
||||
&& !versions4K.Contains(a.ItemId))
|
||||
|| (includeHD
|
||||
&& (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId))
|
||||
&& !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)
|
||||
&& !versions4K.Contains(a.ItemId))
|
||||
|| (include4K
|
||||
&& (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId))))
|
||||
|| context.LinkedChildren.Any(lc =>
|
||||
lc.ParentId == e.Id
|
||||
&& lc.Child!.Width > 0
|
||||
&& ((includeSD && lc.Child.Width < HDWidth)
|
||||
|| (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
|
||||
|| (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
|
||||
&& ((includeSD
|
||||
&& ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId))
|
||||
&& !versionsHd.Contains(lc.ChildId)
|
||||
&& !versions4K.Contains(lc.ChildId))
|
||||
|| (includeHD
|
||||
&& (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId))
|
||||
&& !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight)
|
||||
&& !versions4K.Contains(lc.ChildId))
|
||||
|| (include4K
|
||||
&& (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId))))))));
|
||||
}
|
||||
|
||||
// Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
|
||||
if (minWidth.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Width >= minWidth);
|
||||
var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth);
|
||||
baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.MinHeight.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight);
|
||||
var minHeight = filter.MinHeight;
|
||||
var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight);
|
||||
baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id));
|
||||
}
|
||||
|
||||
// An upper bound inverts that: it is met only if no version breaches it, since the item's
|
||||
// resolution is the best its version group offers.
|
||||
if (maxWidth.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
|
||||
var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth);
|
||||
baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.MaxHeight.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
|
||||
var maxHeight = filter.MaxHeight;
|
||||
var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight);
|
||||
baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsLocked.HasValue)
|
||||
@@ -761,104 +812,144 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoAudioTrackWithLanguage;
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
|
||||
var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang);
|
||||
// A track only an alternate version carries still belongs to the item a caller sees, so the
|
||||
// item's own streams alone do not decide this. Same for every stream filter below.
|
||||
var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithAudio.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
|
||||
var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
|
||||
var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoSubtitleTrackWithLanguage;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
|
||||
var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (filter.HasSubtitles.HasValue)
|
||||
{
|
||||
var hasSubtitles = filter.HasSubtitles.Value;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles());
|
||||
var criteria = new HasSubtitles();
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
if (hasSubtitles)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
|
||||
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
|
||||
|| versionsWithSubtitles.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.SubtitleLanguages.Count > 0)
|
||||
{
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages));
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
|
||||
&& (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
|
||||
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
|
||||
&& (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
|
||||
|| versionsWithSubtitles.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (filter.AudioLanguages.Count > 0)
|
||||
{
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages));
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages);
|
||||
var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
|
||||
&& (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
|
||||
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
|
||||
&& (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
|
||||
|| versionsWithAudio.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (filter.HasChapterImages.HasValue)
|
||||
{
|
||||
var hasChapterImages = filter.HasChapterImages.Value;
|
||||
var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages());
|
||||
var criteria = new HasChapterImages();
|
||||
var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
if (hasChapterImages)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null))
|
||||
(!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null)
|
||||
|| versionsWithChapterImages.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null))
|
||||
(!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)
|
||||
&& !versionsWithChapterImages.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
|
||||
}
|
||||
}
|
||||
|
||||
+201
-120
@@ -8,7 +8,7 @@ using Jellyfin.Database.Implementations.MatchCriteria;
|
||||
namespace Jellyfin.Database.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for querying item hierarchies using iterative traversal.
|
||||
/// Provides methods for querying item hierarchies.
|
||||
/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
|
||||
/// </summary>
|
||||
public static class DescendantQueryHelper
|
||||
@@ -32,11 +32,18 @@ public static class DescendantQueryHelper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var descendants = TraverseHierarchyDown(context, [parentId]);
|
||||
var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId);
|
||||
|
||||
descendants.Remove(parentId);
|
||||
var hierarchyDescendants = ClosureDescendants(context, closureRoots);
|
||||
|
||||
return descendants.AsQueryable();
|
||||
var linkedDescendants = context.LinkedChildren
|
||||
.WhereOneOrMany(linkRoots, e => e.ParentId)
|
||||
.Select(e => e.ChildId);
|
||||
|
||||
return hierarchyDescendants
|
||||
.Concat(linkedDescendants)
|
||||
.Where(e => !e.Equals(parentId))
|
||||
.Distinct();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,11 +58,9 @@ public static class DescendantQueryHelper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var descendants = TraverseHierarchyDownOwned(context, [parentId]);
|
||||
|
||||
descendants.Remove(parentId);
|
||||
|
||||
return descendants.AsQueryable();
|
||||
return ClosureDescendants(context, [parentId])
|
||||
.Where(e => !e.Equals(parentId))
|
||||
.Distinct();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -76,11 +81,11 @@ public static class DescendantQueryHelper
|
||||
return [];
|
||||
}
|
||||
|
||||
var seedSet = new HashSet<Guid>(parentIds);
|
||||
var descendants = TraverseHierarchyDownOwned(context, seedSet);
|
||||
var descendants = ClosureDescendants(context, parentIds)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
// Remove the seed IDs — callers want only descendants
|
||||
descendants.ExceptWith(seedSet);
|
||||
descendants.ExceptWith(parentIds);
|
||||
|
||||
return descendants;
|
||||
}
|
||||
@@ -96,28 +101,106 @@ public static class DescendantQueryHelper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(criteria);
|
||||
var matchingItemIds = criteria switch
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the
|
||||
/// criteria.
|
||||
/// </summary>
|
||||
/// <param name="context">Database context.</param>
|
||||
/// <param name="criteria">The matching criteria to apply.</param>
|
||||
/// <returns>Queryable of primary version item IDs.</returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static IQueryable<Guid> 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)
|
||||
{
|
||||
HasSubtitles => context.MediaStreamInfos
|
||||
.Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle)
|
||||
.Select(ms => ms.ItemId)
|
||||
.Distinct()
|
||||
.ToHashSet(),
|
||||
HasChapterImages => context.Chapters
|
||||
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<Guid> MatchingMediaOwnerIds(JellyfinDbContext context, FolderMatchCriteria criteria)
|
||||
=> criteria is HasChapterImages
|
||||
? context.Chapters
|
||||
.Where(c => c.ImagePath != null)
|
||||
.Select(c => c.ItemId)
|
||||
.Distinct()
|
||||
.ToHashSet(),
|
||||
HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m),
|
||||
: 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<MediaStreamInfo> 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}")
|
||||
};
|
||||
|
||||
var ancestors = TraverseHierarchyUp(context, matchingItemIds);
|
||||
|
||||
return ancestors.AsQueryable();
|
||||
}
|
||||
|
||||
private static HashSet<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
|
||||
private static IQueryable<MediaStreamInfo> GetMatchingMediaStreams(JellyfinDbContext context, HasMediaStreamType criteria)
|
||||
{
|
||||
var query = context.MediaStreamInfos
|
||||
.Where(ms => ms.StreamType == criteria.StreamType
|
||||
@@ -130,130 +213,128 @@ public static class DescendantQueryHelper
|
||||
query = query.Where(ms => ms.IsExternal == isExternal);
|
||||
}
|
||||
|
||||
return query.Select(ms => ms.ItemId).Distinct().ToHashSet();
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses DOWN the hierarchy from parent folders to find all descendants.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> TraverseHierarchyDown(JellyfinDbContext context, ICollection<Guid> startIds)
|
||||
private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots)
|
||||
{
|
||||
var visited = new HashSet<Guid>(startIds);
|
||||
var folderStack = new HashSet<Guid>(startIds);
|
||||
var direct = context.AncestorIds
|
||||
.WhereOneOrMany(roots, e => e.ParentItemId)
|
||||
.Select(e => e.ItemId);
|
||||
|
||||
while (folderStack.Count != 0)
|
||||
// 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<Guid> ResolveLinkParents(JellyfinDbContext context, IQueryable<Guid> matchingItemIds, IQueryable<Guid> 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 currentFolders = folderStack.ToArray();
|
||||
folderStack.Clear();
|
||||
var containingFolders = context.AncestorIds
|
||||
.WhereOneOrMany(frontier, e => e.ItemId)
|
||||
.Select(e => e.ParentItemId);
|
||||
|
||||
var directChildren = context.AncestorIds
|
||||
.WhereOneOrMany(currentFolders, e => e.ParentItemId)
|
||||
.Select(e => e.ItemId)
|
||||
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();
|
||||
|
||||
var linkedChildren = context.LinkedChildren
|
||||
.WhereOneOrMany(currentFolders, e => e.ParentId)
|
||||
.Select(e => e.ChildId)
|
||||
.ToArray();
|
||||
|
||||
var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray();
|
||||
|
||||
if (allChildren.Length == 0)
|
||||
frontier = [];
|
||||
foreach (var id in next)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var childFolders = context.BaseItems
|
||||
.WhereOneOrMany(allChildren, e => e.Id)
|
||||
.Where(e => e.IsFolder)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var childId in allChildren)
|
||||
{
|
||||
if (visited.Add(childId) && childFolders.Contains(childId))
|
||||
// Cyclic links terminate on the resolved set.
|
||||
if (resolved.Add(id))
|
||||
{
|
||||
folderStack.Add(childId);
|
||||
frontier.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
return [.. resolved];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection<Guid> startIds)
|
||||
// Resolves the roots the descendant sub-selects are anchored on: those contributing their closure,
|
||||
// and those contributing their linked children.
|
||||
private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId)
|
||||
{
|
||||
var visited = new HashSet<Guid>(startIds);
|
||||
var folderStack = new HashSet<Guid>(startIds);
|
||||
var closureRoots = new List<Guid> { parentId };
|
||||
var linkRoots = new List<Guid> { parentId };
|
||||
var visited = new HashSet<Guid> { parentId };
|
||||
var frontier = new List<Guid> { parentId };
|
||||
|
||||
while (folderStack.Count != 0)
|
||||
while (frontier.Count != 0)
|
||||
{
|
||||
var currentFolders = folderStack.ToArray();
|
||||
folderStack.Clear();
|
||||
var closureIds = ClosureDescendants(context, frontier);
|
||||
|
||||
var directChildren = context.AncestorIds
|
||||
.WhereOneOrMany(currentFolders, e => e.ParentItemId)
|
||||
.Select(e => e.ItemId)
|
||||
.ToArray();
|
||||
var linkedIds = context.LinkedChildren
|
||||
.WhereOneOrMany(frontier, e => e.ParentId)
|
||||
.Select(e => e.ChildId);
|
||||
|
||||
if (directChildren.Length == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var childFolders = context.BaseItems
|
||||
.WhereOneOrMany(directChildren, e => e.Id)
|
||||
.Where(e => e.IsFolder)
|
||||
var linkedFolders = context.BaseItems
|
||||
.Where(e => e.IsFolder && linkedIds.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var childId in directChildren)
|
||||
{
|
||||
if (visited.Add(childId) && childFolders.Contains(childId))
|
||||
{
|
||||
folderStack.Add(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses UP the hierarchy from items to find all ancestor folders.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> TraverseHierarchyUp(JellyfinDbContext context, ICollection<Guid> startIds)
|
||||
{
|
||||
var ancestors = new HashSet<Guid>();
|
||||
var itemStack = new HashSet<Guid>(startIds);
|
||||
|
||||
while (itemStack.Count != 0)
|
||||
{
|
||||
var currentItems = itemStack.ToArray();
|
||||
itemStack.Clear();
|
||||
|
||||
var ancestorParents = context.AncestorIds
|
||||
.WhereOneOrMany(currentItems, e => e.ItemId)
|
||||
.Select(e => e.ParentItemId)
|
||||
.ToArray();
|
||||
|
||||
var linkedParents = context.LinkedChildren
|
||||
.WhereOneOrMany(currentItems, e => e.ChildId)
|
||||
// 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();
|
||||
|
||||
foreach (var parentId in ancestorParents.Concat(linkedParents))
|
||||
frontier = [];
|
||||
foreach (var id in linkOwners.Concat(linkedFolders))
|
||||
{
|
||||
if (ancestors.Add(parentId))
|
||||
if (!visited.Add(id))
|
||||
{
|
||||
itemStack.Add(parentId);
|
||||
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 ancestors;
|
||||
return (closureRoots, linkRoots);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -13,5 +13,9 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration<MediaStream
|
||||
public void Configure(EntityTypeBuilder<MediaStreamInfo> builder)
|
||||
{
|
||||
builder.HasKey(e => new { e.ItemId, e.StreamIndex });
|
||||
|
||||
// Covering index for the stream filters. ItemId comes second because it is what they project and
|
||||
// dedupe on; Language and IsExternal follow only to keep their predicates off the table.
|
||||
builder.HasIndex(e => new { e.StreamType, e.ItemId, e.Language, e.IsExternal });
|
||||
}
|
||||
}
|
||||
|
||||
+1813
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaStreamFilterIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
|
||||
table: "MediaStreamInfos",
|
||||
columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
|
||||
table: "MediaStreamInfos");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -1015,6 +1015,8 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
|
||||
b.HasKey("ItemId", "StreamIndex");
|
||||
|
||||
b.HasIndex("StreamType", "ItemId", "Language", "IsExternal");
|
||||
|
||||
b.ToTable("MediaStreamInfos");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
|
||||
+2
@@ -1012,6 +1012,8 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("ItemId", "StreamIndex");
|
||||
|
||||
b.HasIndex("StreamType", "ItemId", "Language", "IsExternal");
|
||||
|
||||
b.ToTable("MediaStreamInfos");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
|
||||
+1
-32
@@ -4,11 +4,6 @@ using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
@@ -18,22 +13,10 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
|
||||
/// and evaluate correctly on the SQLite provider.
|
||||
/// </summary>
|
||||
public sealed class AlternateVersionQueryTranslationTests : IDisposable
|
||||
public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
|
||||
public AlternateVersionQueryTranslationTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -220,18 +203,4 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
|
||||
ctx.SaveChanges();
|
||||
return (user.Id, primary.Id, versionA.Id, versionB.Id);
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-50
@@ -3,17 +3,8 @@ using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
|
||||
|
||||
@@ -24,46 +15,16 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count
|
||||
/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total.
|
||||
/// </summary>
|
||||
public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable
|
||||
public sealed class BaseItemRepositoryByNameTotalCountTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly BaseItemRepository _repository;
|
||||
private readonly ItemTypeLookup _itemTypeLookup;
|
||||
|
||||
public BaseItemRepositoryByNameTotalCountTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_itemTypeLookup = new ItemTypeLookup();
|
||||
|
||||
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
|
||||
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
|
||||
|
||||
_repository = new BaseItemRepository(
|
||||
factory.Object,
|
||||
new Mock<IServerApplicationHost>().Object,
|
||||
_itemTypeLookup,
|
||||
serverConfigurationManager.Object,
|
||||
NullLogger<BaseItemRepository>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
_repository = CreateBaseItemRepository(_itemTypeLookup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -187,13 +148,4 @@ public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable
|
||||
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-50
@@ -3,64 +3,25 @@ using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class BaseItemRepositoryGroupingTests : IDisposable
|
||||
public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly BaseItemRepository _repository;
|
||||
private readonly string _movieTypeName;
|
||||
|
||||
public BaseItemRepositoryGroupingTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
var itemTypeLookup = new ItemTypeLookup();
|
||||
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
|
||||
|
||||
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
|
||||
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
|
||||
|
||||
_repository = new BaseItemRepository(
|
||||
factory.Object,
|
||||
new Mock<IServerApplicationHost>().Object,
|
||||
itemTypeLookup,
|
||||
serverConfigurationManager.Object,
|
||||
NullLogger<BaseItemRepository>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
_repository = CreateBaseItemRepository(itemTypeLookup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,13 +93,4 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable
|
||||
IsVirtualItem = false
|
||||
};
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Xunit;
|
||||
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the filters resolving "folders with a matching descendant" through
|
||||
/// <see cref="DescendantQueryHelper.GetFolderIdsMatching"/>, positive and negated.
|
||||
/// </summary>
|
||||
public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture
|
||||
{
|
||||
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
|
||||
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
|
||||
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
|
||||
|
||||
private readonly BaseItemRepository _repository;
|
||||
|
||||
private readonly Guid _library = Guid.NewGuid();
|
||||
private readonly Guid _withSubtitles = Guid.NewGuid();
|
||||
private readonly Guid _withoutSubtitles = Guid.NewGuid();
|
||||
private readonly Guid _collection = Guid.NewGuid();
|
||||
private readonly Guid _linkedSeries = Guid.NewGuid();
|
||||
private readonly Guid _linkedEpisode = Guid.NewGuid();
|
||||
|
||||
// A version group in a library of its own, so it cannot move the assertions above: an SD primary
|
||||
// that carries nothing, and a 4K second file carrying the subtitles, chapter image and audio.
|
||||
private readonly Guid _versionLibrary = Guid.NewGuid();
|
||||
private readonly Guid _versionedMovie = Guid.NewGuid();
|
||||
private readonly Guid _alternateVersion = Guid.NewGuid();
|
||||
|
||||
// A series in the same library, so the folder branch of the resolution filter has a version group
|
||||
// to reach through as well: an SD episode whose second file is 4K.
|
||||
private readonly Guid _versionedSeries = Guid.NewGuid();
|
||||
private readonly Guid _versionedEpisode = Guid.NewGuid();
|
||||
private readonly Guid _episodeAlternate = Guid.NewGuid();
|
||||
|
||||
// An unprobed primary: only its second file carries dimensions, and they are SD.
|
||||
private readonly Guid _unprobedMovie = Guid.NewGuid();
|
||||
private readonly Guid _unprobedAlternate = Guid.NewGuid();
|
||||
|
||||
// A plain SD movie with no second file, as the control the version groups are read against.
|
||||
private readonly Guid _sdMovie = Guid.NewGuid();
|
||||
|
||||
// An unprobed primary whose only second file is HD, so the HD bucket has to place it off nulls.
|
||||
private readonly Guid _hdOnlyByVersion = Guid.NewGuid();
|
||||
private readonly Guid _hdOnlyAlternate = Guid.NewGuid();
|
||||
|
||||
// Three files for one movie: the HD one would place it in the HD bucket on its own, the 4K one has
|
||||
// to win. Only a group holding both can tell the HD bucket's upper guard from its lower one.
|
||||
private readonly Guid _threeWayMovie = Guid.NewGuid();
|
||||
private readonly Guid _threeWayHd = Guid.NewGuid();
|
||||
private readonly Guid _threeWay4K = Guid.NewGuid();
|
||||
|
||||
public BaseItemRepositoryStreamFilterTests()
|
||||
{
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
Seed(ctx);
|
||||
}
|
||||
|
||||
_repository = CreateBaseItemRepository(new ItemTypeLookup());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_MatchesTheItemAndItsParentFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
|
||||
|
||||
Assert.Contains(_withSubtitles, ids);
|
||||
// The library is a folder, and it has a descendant with subtitles.
|
||||
Assert.Contains(_library, ids);
|
||||
Assert.DoesNotContain(_withoutSubtitles, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_Negated_ExcludesTheItemAndItsParentFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
|
||||
|
||||
Assert.Contains(_withoutSubtitles, ids);
|
||||
Assert.DoesNotContain(_withSubtitles, ids);
|
||||
Assert.DoesNotContain(_library, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubtitleLanguages_MatchesTheRequestedLanguageOnly()
|
||||
{
|
||||
Assert.Contains(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
|
||||
Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNoSubtitleTrackWithLanguage_ExcludesTheMatchingItemAndFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
|
||||
|
||||
Assert.Contains(_withoutSubtitles, ids);
|
||||
Assert.DoesNotContain(_withSubtitles, ids);
|
||||
Assert.DoesNotContain(_library, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_MatchesACollectionLinkingAFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
|
||||
|
||||
Assert.Contains(_linkedSeries, ids);
|
||||
Assert.Contains(_collection, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_Negated_ExcludesACollectionLinkingAFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
|
||||
|
||||
Assert.DoesNotContain(_linkedSeries, ids);
|
||||
Assert.DoesNotContain(_collection, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChapterImages_MatchesTheItemAndItsParentFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true });
|
||||
|
||||
Assert.Contains(_withSubtitles, ids);
|
||||
Assert.Contains(_library, ids);
|
||||
Assert.DoesNotContain(_withoutSubtitles, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_MatchesAnItemWhoseAlternateVersionCarriesThem()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
|
||||
|
||||
Assert.Contains(_versionedMovie, ids);
|
||||
Assert.Contains(_versionLibrary, ids);
|
||||
// The second file is never listed on its own, which is why its tracks have to count for the primary.
|
||||
Assert.DoesNotContain(_alternateVersion, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_Negated_ExcludesAnItemWhoseAlternateVersionCarriesThem()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
|
||||
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.DoesNotContain(_versionLibrary, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubtitleLanguages_MatchesTheLanguageOnAnAlternateVersion()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNoSubtitleTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
|
||||
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.DoesNotContain(_versionLibrary, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioLanguages_MatchesTheLanguageOnAnAlternateVersion()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { AudioLanguages = ["fre"] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNoAudioTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
|
||||
{
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = "fre" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChapterImages_MatchesAnItemWhoseAlternateVersionCarriesThem()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Is4K_MatchesAnItemWhoseAlternateVersionIs4K()
|
||||
{
|
||||
// The primary file is SD; the resolution a caller can actually play is the 4K second file's.
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinWidth_MatchesAnItemWhoseAlternateVersionIsWideEnough()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
|
||||
Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxWidth_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
|
||||
{
|
||||
// The SD primary is narrow enough on its own, but the 4K second file is what a caller would play.
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
|
||||
Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxHeight_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
|
||||
{
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
|
||||
Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_False_ExcludesAnSdPrimaryWhoseAlternateVersionIsBetter()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false });
|
||||
|
||||
// 720x480 on its own, but the version group tops out at 4K.
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.Contains(_sdMovie, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_False_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
|
||||
{
|
||||
// The primary carries no dimensions at all; the SD second file is the group's best.
|
||||
Assert.Contains(_unprobedMovie, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_True_ExcludesAnItemWhoseVersionGroupReaches4K()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true });
|
||||
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.DoesNotContain(_unprobedMovie, ids);
|
||||
// The 1920-wide second file alone would say HD; the 4K third file is the group's best.
|
||||
Assert.DoesNotContain(_threeWayMovie, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Is4K_MatchesAnItemWhoseVersionGroupHoldsBothHdAnd4K()
|
||||
{
|
||||
Assert.Contains(_threeWayMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_True_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
|
||||
{
|
||||
// The primary carries no dimensions of its own; the HD second file is the group's best.
|
||||
Assert.Contains(_hdOnlyByVersion, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Is4K_MatchesTheSeriesOfAnEpisodeWhoseAlternateVersionIs4K()
|
||||
{
|
||||
// The folder branch buckets a descendant the same way the item branch buckets a top-level item.
|
||||
Assert.Contains(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_False_ExcludesTheSeriesOfAnSdEpisodeWithABetterAlternateVersion()
|
||||
{
|
||||
// Before the version group was consulted on descendants too, the SD episode alone matched here
|
||||
// while the same pair at top level did not.
|
||||
Assert.DoesNotContain(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("und")]
|
||||
[InlineData("UND")]
|
||||
public void HasNoAudioTrackWithLanguage_TreatsUndeterminedCaseInsensitively(string language)
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = language });
|
||||
|
||||
// The alternate version carries an audio track with no language, which is what "und" stands for,
|
||||
// so the item it is reported against does have one.
|
||||
Assert.DoesNotContain(_unprobedMovie, ids);
|
||||
Assert.Contains(_versionedMovie, ids);
|
||||
}
|
||||
|
||||
private void Seed(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _withSubtitles, Type = MovieType, Name = "With subtitles" });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _withoutSubtitles, Type = MovieType, Name = "Without subtitles" });
|
||||
|
||||
foreach (var itemId in new[] { _withSubtitles, _withoutSubtitles })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _library,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = itemId,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Video,
|
||||
Item = null!
|
||||
});
|
||||
}
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _withSubtitles,
|
||||
StreamIndex = 1,
|
||||
StreamType = MediaStreamTypeEntity.Subtitle,
|
||||
Language = "ger",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
// A collection linking a folder: the match is two edges away, one link then one closure hop.
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _linkedSeries, Type = FolderType, Name = "Linked series", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _linkedEpisode, Type = MovieType, Name = "Linked episode" });
|
||||
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = _linkedEpisode,
|
||||
ParentItemId = _linkedSeries,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
ParentId = _collection,
|
||||
ChildId = _linkedSeries,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = 0
|
||||
});
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _linkedEpisode,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Subtitle,
|
||||
Language = "ger",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
context.Chapters.Add(new Chapter
|
||||
{
|
||||
ItemId = _withSubtitles,
|
||||
ChapterIndex = 0,
|
||||
StartPositionTicks = 0,
|
||||
ImagePath = "/chapter.jpg",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
SeedVersionGroup(context);
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
// An SD primary whose only extras live on a 4K second file, so every filter has to reach through
|
||||
// PrimaryVersionId to answer correctly.
|
||||
private void SeedVersionGroup(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionLibrary, Type = FolderType, Name = "Version library", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionedMovie, Type = MovieType, Name = "Versioned movie", Width = 720, Height = 480 });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _alternateVersion,
|
||||
Type = MovieType,
|
||||
Name = "Versioned movie 4K",
|
||||
PrimaryVersionId = _versionedMovie,
|
||||
Width = 3840,
|
||||
Height = 2160
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _versionedMovie, _alternateVersion })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _alternateVersion,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Subtitle,
|
||||
Language = "ger",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _alternateVersion,
|
||||
StreamIndex = 1,
|
||||
StreamType = MediaStreamTypeEntity.Audio,
|
||||
Language = "fre",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
SeedVersionedSeries(context);
|
||||
SeedUnprobedVersionGroup(context);
|
||||
|
||||
context.Chapters.Add(new Chapter
|
||||
{
|
||||
ItemId = _alternateVersion,
|
||||
ChapterIndex = 0,
|
||||
StartPositionTicks = 0,
|
||||
ImagePath = "/alternate-chapter.jpg",
|
||||
Item = null!
|
||||
});
|
||||
}
|
||||
|
||||
// The same SD primary / 4K second file pair one level down, so the resolution filter has to answer
|
||||
// for the series off its descendants.
|
||||
private void SeedVersionedSeries(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionedSeries, Type = FolderType, Name = "Versioned series", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionedEpisode, Type = MovieType, Name = "Versioned episode", Width = 720, Height = 480 });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _episodeAlternate,
|
||||
Type = MovieType,
|
||||
Name = "Versioned episode 4K",
|
||||
PrimaryVersionId = _versionedEpisode,
|
||||
Width = 3840,
|
||||
Height = 2160
|
||||
});
|
||||
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = _versionedSeries,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _versionedEpisode, _episodeAlternate })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionedSeries,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A primary that was never probed, so only its second file can place it in a bucket. Its audio track
|
||||
// declares no language, which is what the "und" filters stand in for.
|
||||
private void SeedUnprobedVersionGroup(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _sdMovie, Type = MovieType, Name = "SD movie", Width = 720, Height = 480 });
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = _sdMovie,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _unprobedMovie, Type = MovieType, Name = "Unprobed movie" });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _unprobedAlternate,
|
||||
Type = MovieType,
|
||||
Name = "Unprobed movie SD",
|
||||
PrimaryVersionId = _unprobedMovie,
|
||||
Width = 720,
|
||||
Height = 480
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _unprobedMovie, _unprobedAlternate })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _unprobedAlternate,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Audio,
|
||||
Item = null!
|
||||
});
|
||||
|
||||
SeedMixedVersionGroups(context);
|
||||
}
|
||||
|
||||
// The two groups that separate the HD bucket's lower bound from its upper one: one that only a 4K
|
||||
// third file keeps out of HD, and one that only an HD second file puts into it.
|
||||
private void SeedMixedVersionGroups(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _threeWayMovie, Type = MovieType, Name = "Three-way movie", Width = 720, Height = 480 });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _threeWayHd,
|
||||
Type = MovieType,
|
||||
Name = "Three-way movie HD",
|
||||
PrimaryVersionId = _threeWayMovie,
|
||||
Width = 1920,
|
||||
Height = 1080
|
||||
});
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _threeWay4K,
|
||||
Type = MovieType,
|
||||
Name = "Three-way movie 4K",
|
||||
PrimaryVersionId = _threeWayMovie,
|
||||
Width = 3840,
|
||||
Height = 2160
|
||||
});
|
||||
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _hdOnlyByVersion, Type = MovieType, Name = "HD only by version" });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _hdOnlyAlternate,
|
||||
Type = MovieType,
|
||||
Name = "HD only by version, HD file",
|
||||
PrimaryVersionId = _hdOnlyByVersion,
|
||||
Width = 1920,
|
||||
Height = 1080
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _threeWayMovie, _threeWayHd, _threeWay4K, _hdOnlyByVersion, _hdOnlyAlternate })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.MatchCriteria;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that
|
||||
/// they stay sub-selects instead of inlining every descendant id into the statement.
|
||||
/// </summary>
|
||||
public sealed class DescendantQueryHelperTests : SqliteDbTestFixture
|
||||
{
|
||||
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
|
||||
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
|
||||
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
|
||||
|
||||
private readonly Dictionary<Guid, int> _linkCounters = new();
|
||||
|
||||
public DescendantQueryHelperTests()
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent()
|
||||
{
|
||||
var library = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var season = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, library);
|
||||
AddFolder(ctx, series);
|
||||
AddFolder(ctx, season);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
// AncestorIds is a closure: production writes one row per ancestor, not just the parent.
|
||||
AddAncestors(ctx, series, library);
|
||||
AddAncestors(ctx, season, series, library);
|
||||
AddAncestors(ctx, episode, season, series, library);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { series, season, episode }.Order(), descendants.Order());
|
||||
Assert.DoesNotContain(library, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_LinkedFolder_IncludesItsOwnDescendants()
|
||||
{
|
||||
var boxSet = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
AddAncestors(ctx, episode, series);
|
||||
AddLink(ctx, boxSet, series);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet();
|
||||
|
||||
Assert.Contains(series, descendants);
|
||||
Assert.Contains(episode, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout so that a missing termination guard fails the test instead of hanging the run.
|
||||
[Fact(Timeout = 30000)]
|
||||
public void GetAllDescendantIds_NestedLinks_AreFollowedAndCyclesTerminate()
|
||||
{
|
||||
var outer = Guid.NewGuid();
|
||||
var inner = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, outer, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, inner, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
|
||||
AddLink(ctx, outer, inner);
|
||||
AddLink(ctx, inner, movie);
|
||||
// The traversal must not spin on this cycle.
|
||||
AddLink(ctx, inner, outer);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, outer).ToHashSet();
|
||||
|
||||
Assert.Contains(inner, descendants);
|
||||
Assert.Contains(movie, descendants);
|
||||
Assert.DoesNotContain(outer, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_LinksOfNonFolders_AreNotFollowed()
|
||||
{
|
||||
var library = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
var alternateVersion = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, library);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
AddItem(ctx, alternateVersion, MovieType);
|
||||
|
||||
AddAncestors(ctx, movie, library);
|
||||
// An alternate version hangs off the movie by link, and the movie is not a folder.
|
||||
AddLink(ctx, movie, alternateVersion);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
|
||||
|
||||
Assert.Contains(movie, descendants);
|
||||
Assert.DoesNotContain(alternateVersion, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
|
||||
{
|
||||
var userRoot = Guid.NewGuid();
|
||||
var collectionFolder = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var linkedMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, userRoot);
|
||||
AddFolder(ctx, collectionFolder);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, linkedMovie, MovieType);
|
||||
|
||||
// An item carries its own chain plus its collection folder, but not the user root above
|
||||
// it, so one hop from the user root stops at the collection folder.
|
||||
AddAncestors(ctx, collectionFolder, userRoot);
|
||||
AddAncestors(ctx, series, collectionFolder);
|
||||
AddAncestors(ctx, episode, series, collectionFolder);
|
||||
AddAncestors(ctx, boxSet, collectionFolder);
|
||||
// The box set is only reachable across the seam, and its links have to be followed too.
|
||||
AddLink(ctx, boxSet, linkedMovie);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, userRoot).ToHashSet();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { collectionFolder, series, episode, boxSet, linkedMovie }.Order(),
|
||||
descendants.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOwnedDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
|
||||
{
|
||||
var userRoot = Guid.NewGuid();
|
||||
var collectionFolder = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var linkedMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, userRoot);
|
||||
AddFolder(ctx, collectionFolder);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, linkedMovie, MovieType);
|
||||
|
||||
AddAncestors(ctx, collectionFolder, userRoot);
|
||||
AddAncestors(ctx, series, collectionFolder);
|
||||
AddAncestors(ctx, episode, series, collectionFolder);
|
||||
AddAncestors(ctx, boxSet, collectionFolder);
|
||||
AddLink(ctx, boxSet, linkedMovie);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
// Owned only: the linked movie stays out, or deleting a library would delete it.
|
||||
var expected = new[] { collectionFolder, series, episode, boxSet }.Order();
|
||||
|
||||
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIds(ctx, userRoot).ToHashSet().Order());
|
||||
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [userRoot]).Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_LinkAboveAClosure_ReturnsTheLinkingFolder()
|
||||
{
|
||||
var collections = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var library = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
var otherLibrary = Guid.NewGuid();
|
||||
var otherBoxSet = Guid.NewGuid();
|
||||
var silentMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, collections);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, library);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
AddAncestors(ctx, boxSet, collections);
|
||||
AddAncestors(ctx, series, library);
|
||||
AddAncestors(ctx, episode, series, library);
|
||||
// The link lands on the series, not on the episode that carries the subtitles.
|
||||
AddLink(ctx, boxSet, series);
|
||||
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
|
||||
|
||||
AddFolder(ctx, otherLibrary);
|
||||
AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, silentMovie, MovieType);
|
||||
AddAncestors(ctx, otherBoxSet, collections);
|
||||
AddAncestors(ctx, silentMovie, otherLibrary);
|
||||
AddLink(ctx, otherBoxSet, silentMovie);
|
||||
// A stream of another type: the criteria, not the mere presence of a stream, decides.
|
||||
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
|
||||
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { library, series, boxSet, collections }.Order(), folders.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30000)]
|
||||
public void GetFolderIdsMatching_NestedLinks_AreFollowedAndCyclesTerminate()
|
||||
{
|
||||
var outer = Guid.NewGuid();
|
||||
var inner = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
var silentSet = Guid.NewGuid();
|
||||
var silentMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, outer, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, inner, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
|
||||
AddLink(ctx, outer, inner);
|
||||
AddLink(ctx, inner, movie);
|
||||
// Resolving the link parents must not spin on this cycle.
|
||||
AddLink(ctx, inner, outer);
|
||||
AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle);
|
||||
|
||||
AddItem(ctx, silentSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, silentMovie, MovieType);
|
||||
AddLink(ctx, silentSet, silentMovie);
|
||||
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
|
||||
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { inner, outer }.Order(), folders.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_ClosureSeamAboveTheCollectionFolder_IsCrossed()
|
||||
{
|
||||
var userRoot = Guid.NewGuid();
|
||||
var collectionFolder = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, userRoot);
|
||||
AddFolder(ctx, collectionFolder);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
// The closure is not transitive at this seam: no item records the user root.
|
||||
AddAncestors(ctx, episode, series, collectionFolder);
|
||||
AddAncestors(ctx, series, collectionFolder);
|
||||
AddAncestors(ctx, collectionFolder, userRoot);
|
||||
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { series, collectionFolder, userRoot }.Order(), folders.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_LinkedFolder_MatchesOnLanguageOnly()
|
||||
{
|
||||
var boxSet = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
AddAncestors(ctx, episode, series);
|
||||
AddLink(ctx, boxSet, series);
|
||||
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle, "ger");
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var german = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["ger"]);
|
||||
var french = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["fre"]);
|
||||
|
||||
Assert.Equal(new[] { series, boxSet }.Order(), DescendantQueryHelper.GetFolderIdsMatching(ctx, german).ToHashSet().Order());
|
||||
Assert.Empty(DescendantQueryHelper.GetFolderIdsMatching(ctx, french).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_AlternateVersionLinks_AreNotWalked()
|
||||
{
|
||||
var collections = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var library = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
var alternateVersion = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, collections);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, library);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
AddItem(ctx, alternateVersion, MovieType);
|
||||
|
||||
AddAncestors(ctx, boxSet, collections);
|
||||
AddAncestors(ctx, movie, library);
|
||||
AddAncestors(ctx, alternateVersion, library);
|
||||
// Only the second file carries the subtitles, and it hangs off the movie by an alternate
|
||||
// version link. The movie is not a folder, so that link is not a parent-child edge.
|
||||
AddLink(ctx, movie, alternateVersion, LinkedChildType.LocalAlternateVersion);
|
||||
AddLink(ctx, boxSet, movie);
|
||||
AddStream(ctx, alternateVersion, MediaStreamTypeEntity.Subtitle);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
// The library still matches: the alternate version carries its own closure. The box set does
|
||||
// not, matching the descendant side, which does not follow a non-folder's links either.
|
||||
Assert.Equal([library], folders);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOwnedDescendantIds_IgnoresLinkedChildren()
|
||||
{
|
||||
var boxSet = Guid.NewGuid();
|
||||
var owned = Guid.NewGuid();
|
||||
var linked = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, owned, MovieType);
|
||||
AddItem(ctx, linked, MovieType);
|
||||
|
||||
AddAncestors(ctx, owned, boxSet);
|
||||
AddLink(ctx, boxSet, linked);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIds(ctx, boxSet).ToArray());
|
||||
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [boxSet]).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_StatementSizeDoesNotGrowWithTheLibrary()
|
||||
{
|
||||
var small = SeedLibrary(10);
|
||||
var large = SeedLibrary(500);
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
|
||||
var smallSql = CountingQuery(ctx, small).ToQueryString();
|
||||
var largeSql = CountingQuery(ctx, large).ToQueryString();
|
||||
|
||||
// Reading the ids into memory and handing them back as AsQueryable() makes EF inline one
|
||||
// literal per descendant, which is what allocated megabytes per call.
|
||||
Assert.Equal(smallSql.Length, largeSql.Length);
|
||||
Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal);
|
||||
Assert.Equal(10, CountingQuery(ctx, small).Count());
|
||||
Assert.Equal(500, CountingQuery(ctx, large).Count());
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> CountingQuery(JellyfinDbContext context, Guid libraryId)
|
||||
{
|
||||
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, libraryId);
|
||||
|
||||
return context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => descendantIds.Contains(b.Id))
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
}
|
||||
|
||||
private Guid SeedLibrary(int childCount)
|
||||
{
|
||||
var library = Guid.NewGuid();
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
AddFolder(ctx, library);
|
||||
for (var i = 0; i < childCount; i++)
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
AddItem(ctx, child, MovieType);
|
||||
AddAncestors(ctx, child, library);
|
||||
}
|
||||
|
||||
ctx.SaveChanges();
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
private static void AddFolder(JellyfinDbContext context, Guid id)
|
||||
=> AddItem(context, id, FolderType, isFolder: true);
|
||||
|
||||
private static void AddItem(JellyfinDbContext context, Guid id, string type, bool isFolder = false)
|
||||
=> context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
Name = type + " " + id,
|
||||
IsFolder = isFolder
|
||||
});
|
||||
|
||||
private static void AddStream(JellyfinDbContext context, Guid itemId, MediaStreamTypeEntity type, string? language = null)
|
||||
=> context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = itemId,
|
||||
StreamIndex = 0,
|
||||
StreamType = type,
|
||||
Language = language,
|
||||
Item = null!
|
||||
});
|
||||
|
||||
private static void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
|
||||
{
|
||||
foreach (var ancestorId in ancestorIds)
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = ancestorId,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot.
|
||||
private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId, LinkedChildType childType = LinkedChildType.Manual)
|
||||
{
|
||||
_linkCounters.TryGetValue(parentId, out var sortOrder);
|
||||
_linkCounters[parentId] = sortOrder + 1;
|
||||
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
ParentId = parentId,
|
||||
ChildId = childId,
|
||||
ChildType = childType,
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,49 +3,27 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class ItemPersistenceOwnedRowTests : IDisposable
|
||||
public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly ItemPersistenceService _service;
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ILibraryManager? _previousLibraryManager;
|
||||
private readonly IServerConfigurationManager? _previousConfigurationManager;
|
||||
|
||||
public ItemPersistenceOwnedRowTests()
|
||||
{
|
||||
_applicationPaths = new Mock<IApplicationPaths>().Object;
|
||||
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
// BaseItem resolves these through process-wide statics; restored in Dispose.
|
||||
_previousLibraryManager = BaseItem.LibraryManager;
|
||||
_previousConfigurationManager = BaseItem.ConfigurationManager;
|
||||
@@ -59,20 +37,17 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable
|
||||
configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
|
||||
BaseItem.ConfigurationManager = configurationManager.Object;
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_service = new ItemPersistenceService(
|
||||
factory.Object,
|
||||
CreateDbContextFactory(),
|
||||
new Mock<IServerApplicationHost>().Object,
|
||||
NullLogger<ItemPersistenceService>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
BaseItem.LibraryManager = _previousLibraryManager!;
|
||||
BaseItem.ConfigurationManager = _previousConfigurationManager!;
|
||||
_connection.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -140,10 +115,4 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable
|
||||
book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
|
||||
return book;
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext() => new(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
+2
-34
@@ -4,42 +4,27 @@ using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
|
||||
public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture
|
||||
{
|
||||
private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly PeopleRepository _repository;
|
||||
|
||||
public PeopleRepositoryUpdatePeopleTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
var itemTypeLookup = new ItemTypeLookup();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
ctx.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _itemId,
|
||||
@@ -53,20 +38,12 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_repository = new PeopleRepository(
|
||||
factory.Object,
|
||||
CreateDbContextFactory(),
|
||||
itemTypeLookup,
|
||||
new Mock<IItemQueryHelpers>().Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
|
||||
{
|
||||
@@ -174,13 +151,4 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
|
||||
Role = role
|
||||
};
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
/// <summary>
|
||||
/// Base fixture for the item tests that run against the SQLite provider: one in-memory database per
|
||||
/// test class, plus the wiring the repositories under test need. The connection owns the database, so
|
||||
/// it stays open for the lifetime of the fixture. Derived classes seed in their own constructor.
|
||||
/// </summary>
|
||||
public abstract class SqliteDbTestFixture : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
|
||||
protected SqliteDbTestFixture()
|
||||
{
|
||||
ApplicationPaths = new Mock<IApplicationPaths>().Object;
|
||||
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using var context = CreateDbContext();
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
protected IApplicationPaths ApplicationPaths { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected JellyfinDbContext CreateDbContext() => new(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
|
||||
protected IDbContextFactory<JellyfinDbContext> CreateDbContextFactory()
|
||||
{
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
return factory.Object;
|
||||
}
|
||||
|
||||
protected BaseItemRepository CreateBaseItemRepository(ItemTypeLookup itemTypeLookup)
|
||||
{
|
||||
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
|
||||
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
|
||||
|
||||
return new BaseItemRepository(
|
||||
CreateDbContextFactory(),
|
||||
new Mock<IServerApplicationHost>().Object,
|
||||
itemTypeLookup,
|
||||
serverConfigurationManager.Object,
|
||||
NullLogger<BaseItemRepository>.Instance);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user