diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-22 07:42:56 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-22 07:42:56 +0200 |
| commit | 587f06dccc67a6ad67b43cf8889b6b7605950bcf (patch) | |
| tree | 66deb4bc69c5b6a271ed35f80cdc0afa54dbd145 | |
| parent | 7ad5ff6aa7804855e6805495f6c9ebcf6d459a11 (diff) | |
Multiple fixes and improvements
Co-Authored-By: Cody Robibero <cody@robibe.ro>
3 files changed, 349 insertions, 81 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index d2f8e5060c..623c1ea0ab 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -36,8 +36,8 @@ public sealed partial class BaseItemRepository 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 bool IsUndetermined(string language) - => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase); + 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. @@ -82,40 +82,57 @@ public sealed partial class BaseItemRepository include4K = true; } - // A 4K remux of an SD primary is a version of the same item, so the resolution a caller - // filters on is the best any of the item's versions offers, not just the primary file's. - // The filtered PrimaryVersionId index keeps this to the few items that have versions. - var versionsAtResolution = context.BaseItems - .Where(v => v.PrimaryVersionId != null - && v.Width > 0 - && ((includeSD && v.Width < HDWidth) - || (includeHD && v.Width >= HDWidth && !(v.Width >= UHDWidth || v.Height >= UHDHeight)) - || (include4K && (v.Width >= UHDWidth || v.Height >= UHDHeight)))) - .Select(v => v.PrimaryVersionId!.Value); - - // 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)))) - || versionsAtResolution.Contains(e.Id))) + && ((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. @@ -132,17 +149,19 @@ public sealed partial class BaseItemRepository 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) { - var versionsNarrowEnough = VersionsMatchingDimension(context, v => v.Width <= maxWidth); - baseQuery = baseQuery.Where(e => e.Width <= maxWidth || versionsNarrowEnough.Contains(e.Id)); + var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id)); } if (filter.MaxHeight.HasValue) { var maxHeight = filter.MaxHeight; - var versionsShortEnough = VersionsMatchingDimension(context, v => v.Height <= maxHeight); - baseQuery = baseQuery.Where(e => e.Height <= maxHeight || versionsShortEnough.Contains(e.Id)); + var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight); + baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id)); } if (filter.IsLocked.HasValue) @@ -793,8 +812,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) { - var lang = filter.HasNoAudioTrackWithLanguage; - var undetermined = IsUndetermined(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. @@ -812,8 +831,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoInternalSubtitleTrackWithLanguage; - var undetermined = IsUndetermined(lang); + 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); @@ -829,8 +848,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoExternalSubtitleTrackWithLanguage; - var undetermined = IsUndetermined(lang); + 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); @@ -846,8 +865,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) { - var lang = filter.HasNoSubtitleTrackWithLanguage; - var undetermined = IsUndetermined(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); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 5a17a46d9e..6b08f8dd7e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -102,20 +102,25 @@ public static class DescendantQueryHelper ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); - var matchingItemIds = GetItemIdsMatching(context, criteria); + // 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 => matchingItemIds.Contains(e.ItemId)) + .Where(e => reportedItemIds.Contains(e.ItemId)) .Select(e => e.ParentItemId); - var linkParents = ResolveLinkParents(context, matchingItemIds, hierarchyAncestors); + var linkParents = ResolveLinkParents(context, reportedItemIds, hierarchyAncestors); - // Read back as a sub-select so the result stays composable. LinkedChildren is the cheapest - // source: owning a link is what put an id in the set, and ParentId is its leading key. - var linkedParents = context.LinkedChildren - .WhereOneOrMany(linkParents, e => e.ParentId) - .Select(e => e.ParentId); + // 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) @@ -135,25 +140,6 @@ public static class DescendantQueryHelper } /// <summary> - /// Gets a queryable of the IDs of the items whose media matches the criteria. - /// </summary> - /// <param name="context">Database context.</param> - /// <param name="criteria">The matching criteria to apply.</param> - /// <returns>Queryable of item IDs.</returns> - /// <remarks> - /// An alternate version is a second file for its primary version and is never listed on its own, so a - /// track only that file carries is reported against the primary: the item a caller can actually see. - /// </remarks> - public static IQueryable<Guid> GetItemIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(criteria); - - return MatchingMediaOwners(context, criteria) - .Select(e => e.PrimaryVersionId ?? e.Id); - } - - /// <summary> /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the /// criteria. /// </summary> @@ -170,23 +156,47 @@ public static class DescendantQueryHelper ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); - return MatchingMediaOwners(context, criteria) - .Where(e => e.PrimaryVersionId.HasValue) - .Select(e => e.PrimaryVersionId!.Value); + // 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) + { + 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 items whose own media matches, as their BaseItems rows so the version group can be read off - // them. One definition of "matches" per criteria, so the projections above cannot drift apart. - private static IQueryable<BaseItemEntity> MatchingMediaOwners(JellyfinDbContext context, FolderMatchCriteria criteria) + // 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() + : 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) - .Select(ms => ms.Item), - HasChapterImages => context.Chapters - .Where(c => c.ImagePath != null) - .Select(c => c.Item), - HasMediaStreamType m => GetMatchingMediaStreams(context, m).Select(ms => ms.Item), + .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle), + HasMediaStreamType m => GetMatchingMediaStreams(context, m), _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") }; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs index 12a7fc1aef..4e8d84850b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs @@ -35,6 +35,29 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture 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()) @@ -179,6 +202,90 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture 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 }); @@ -302,6 +409,9 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture Item = null! }); + SeedVersionedSeries(context); + SeedUnprobedVersionGroup(context); + context.Chapters.Add(new Chapter { ItemId = _alternateVersion, @@ -311,4 +421,133 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture 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! + }); + } + } } |
