aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-08-22 08:45:42 -0400
committerGitHub <noreply@github.com>2026-08-22 08:45:42 -0400
commit0d4cbb999bbac9920caeeb5cd7174b1dc9758bff (patch)
treeac3b70bb41af69de26d59e71465fc99db42273ed
parentc04f11bd76acd19a1440bbb4351160642a09ef9c (diff)
parent587f06dccc67a6ad67b43cf8889b6b7605950bcf (diff)
Merge pull request #17607 from Shadowghost/optimize-db-helper-memory
Optimize query helper memory
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs183
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs315
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs1813
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs27
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs33
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs52
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs52
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs553
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs526
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs39
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs36
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs85
15 files changed, 3358 insertions, 364 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 4be9b04baa..623c1ea0ab 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -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)));
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
index bfd0fac34a..6b08f8dd7e 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
@@ -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)
- {
- var currentFolders = folderStack.ToArray();
- folderStack.Clear();
+ // 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);
- var directChildren = context.AncestorIds
- .WhereOneOrMany(currentFolders, e => e.ParentItemId)
- .Select(e => e.ItemId)
- .ToArray();
+ return direct.Concat(indirect);
+ }
- var linkedChildren = context.LinkedChildren
- .WhereOneOrMany(currentFolders, e => e.ParentId)
- .Select(e => e.ChildId)
- .ToArray();
+ // 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 containingFolders = context.AncestorIds
+ .WhereOneOrMany(frontier, e => e.ItemId)
+ .Select(e => e.ParentItemId);
- var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray();
+ var directLinkParents = containerLinks
+ .WhereOneOrMany(frontier, e => e.ChildId)
+ .Select(e => e.ParentId);
- if (allChildren.Length == 0)
- {
- break;
- }
+ var indirectLinkParents = containerLinks
+ .Where(e => containingFolders.Contains(e.ChildId))
+ .Select(e => e.ParentId);
- var childFolders = context.BaseItems
- .WhereOneOrMany(allChildren, e => e.Id)
- .Where(e => e.IsFolder)
- .Select(e => e.Id)
- .ToHashSet();
+ var next = directLinkParents
+ .Concat(indirectLinkParents)
+ .Distinct()
+ .ToArray();
- foreach (var childId in allChildren)
+ frontier = [];
+ foreach (var id in next)
{
- 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 directChildren = context.AncestorIds
- .WhereOneOrMany(currentFolders, e => e.ParentItemId)
- .Select(e => e.ItemId)
- .ToArray();
+ var closureIds = ClosureDescendants(context, frontier);
- if (directChildren.Length == 0)
- {
- break;
- }
+ var linkedIds = context.LinkedChildren
+ .WhereOneOrMany(frontier, e => e.ParentId)
+ .Select(e => e.ChildId);
- 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)
+ // Folders whose own links have to be followed. Driven off LinkedChildren because owning a
+ // link is the rare property, so the folder check only reaches rows that can qualify. That
+ // check stays: a non-folder owns links too (a movie and its alternate versions).
+ var linkOwners = context.LinkedChildren
+ .Where(e => (closureIds.Contains(e.ParentId) || linkedIds.Contains(e.ParentId))
+ && e.Parent!.IsFolder)
+ .Select(e => e.ParentId)
+ .Distinct()
+ .ToArray();
+
+ frontier = [];
+ foreach (var id in linkOwners.Concat(linkedFolders))
{
- if (visited.Add(childId) && childFolders.Contains(childId))
+ if (!visited.Add(id))
{
- folderStack.Add(childId);
+ continue;
}
- }
- }
- 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);
+ frontier.Add(id);
+ linkRoots.Add(id);
- 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)
- .Select(e => e.ParentId)
- .ToArray();
-
- foreach (var parentId in ancestorParents.Concat(linkedParents))
- {
- if (ancestors.Add(parentId))
+ // Only a folder reached through a link adds a closure the roots so far do not cover.
+ if (linkedFolders.Contains(id))
{
- itemStack.Add(parentId);
+ closureRoots.Add(id);
}
}
}
- return ancestors;
+ return (closureRoots, linkRoots);
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
index afa9eee363..48c537bbd3 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
@@ -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 });
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs
new file mode 100644
index 0000000000..afa6840a97
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs
@@ -0,0 +1,1813 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20260812050902_AddMediaStreamFilterIndex")]
+ partial class AddMediaStreamFilterIndex
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("PrimaryVersionId")
+ .HasFilter("\"PrimaryVersionId\" IS NOT NULL");
+
+ b.HasIndex("SeasonId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("SeriesName");
+
+ b.HasIndex("ExtraType", "OwnerId");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "CleanName");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem")
+ .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "SortName");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detached from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId", "ImageType");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ItemId", "ProviderValue");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.Property<Guid>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ChildId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChildType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ParentId", "SortOrder");
+
+ b.HasIndex("ChildId", "ChildType");
+
+ b.HasIndex("ParentId", "ChildType");
+
+ b.ToTable("LinkedChildren", (string)null);
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsOriginal")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.HasIndex("StreamType", "ItemId", "Language", "IsExternal");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId", "Role");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.HasIndex("PeopleId", "ItemId");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("NormalizedUsername")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedUsername")
+ .IsUnique();
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.HasIndex("UserId", "IsFavorite", "ItemId");
+
+ b.HasIndex("UserId", "ItemId", "LastPlayedDate");
+
+ b.HasIndex("UserId", "Played", "ItemId");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner")
+ .WithMany("Extras")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.NoAction);
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent")
+ .WithMany("DirectChildren")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("DirectParent");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child")
+ .WithMany("LinkedChildOfEntities")
+ .HasForeignKey("ChildId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent")
+ .WithMany("LinkedChildEntities")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.Navigation("Child");
+
+ b.Navigation("Parent");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("DirectChildren");
+
+ b.Navigation("Extras");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LinkedChildEntities");
+
+ b.Navigation("LinkedChildOfEntities");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs
new file mode 100644
index 0000000000..75187c6c4b
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs
@@ -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");
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs
index 9c5d2cb36c..c41b8b0106 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs
@@ -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);
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
index 35a62d4907..0c35ccc992 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
@@ -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);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
index b7fca74310..2d520f8b8b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -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();
- }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
index f675621e21..0cee47f660 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
@@ -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));
- }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
index 083f725db9..535961a66c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -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));
- }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
new file mode 100644
index 0000000000..4e8d84850b
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
@@ -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!
+ });
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
new file mode 100644
index 0000000000..f2ecfadd50
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
@@ -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
+ });
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
index 6324706452..82614c3156 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
@@ -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));
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
index 70d8e1f833..54565c5787 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
@@ -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));
- }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
new file mode 100644
index 0000000000..87efa8fea5
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -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();
+ }
+ }
+}