Count alternate versions in the media filters and align filter conditions
This commit is contained in:
@@ -35,6 +35,18 @@ public sealed partial class BaseItemRepository
|
|||||||
// instance across several lambdas, and this filter is combined into a tree more than once.
|
// 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;
|
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);
|
||||||
|
|
||||||
|
// 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 />
|
/// <inheritdoc />
|
||||||
public IQueryable<BaseItemEntity> TranslateQuery(
|
public IQueryable<BaseItemEntity> TranslateQuery(
|
||||||
IQueryable<BaseItemEntity> baseQuery,
|
IQueryable<BaseItemEntity> baseQuery,
|
||||||
@@ -70,15 +82,28 @@ public sealed partial class BaseItemRepository
|
|||||||
include4K = true;
|
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).
|
// Non-folders: check own resolution directly (no subquery).
|
||||||
// Folders (Series, BoxSets): EXISTS check on descendants/linked children.
|
// Folders (Series, BoxSets): EXISTS check on descendants/linked children.
|
||||||
// Using navigation properties (a.Item, lc.Child) produces efficient
|
// Using navigation properties (a.Item, lc.Child) produces efficient
|
||||||
// EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
|
// EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
|
||||||
baseQuery = baseQuery.Where(e =>
|
baseQuery = baseQuery.Where(e =>
|
||||||
(!e.IsFolder && e.Width > 0
|
(!e.IsFolder
|
||||||
&& ((includeSD && e.Width < HDWidth)
|
&& ((e.Width > 0
|
||||||
|| (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
|
&& ((includeSD && e.Width < HDWidth)
|
||||||
|| (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
|
|| (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
|
||||||
|
|| (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
|
||||||
|
|| versionsAtResolution.Contains(e.Id)))
|
||||||
|| (e.IsFolder
|
|| (e.IsFolder
|
||||||
&& (e.Children!.Any(a =>
|
&& (e.Children!.Any(a =>
|
||||||
a.Item.Width > 0
|
a.Item.Width > 0
|
||||||
@@ -93,24 +118,31 @@ public sealed partial class BaseItemRepository
|
|||||||
|| (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
|
|| (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
|
||||||
if (minWidth.HasValue)
|
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)
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxWidth.HasValue)
|
if (maxWidth.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
|
var versionsNarrowEnough = VersionsMatchingDimension(context, v => v.Width <= maxWidth);
|
||||||
|
baseQuery = baseQuery.Where(e => e.Width <= maxWidth || versionsNarrowEnough.Contains(e.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.MaxHeight.HasValue)
|
if (filter.MaxHeight.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
|
var maxHeight = filter.MaxHeight;
|
||||||
|
var versionsShortEnough = VersionsMatchingDimension(context, v => v.Height <= maxHeight);
|
||||||
|
baseQuery = baseQuery.Where(e => e.Height <= maxHeight || versionsShortEnough.Contains(e.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.IsLocked.HasValue)
|
if (filter.IsLocked.HasValue)
|
||||||
@@ -762,103 +794,143 @@ public sealed partial class BaseItemRepository
|
|||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
||||||
{
|
{
|
||||||
var lang = filter.HasNoAudioTrackWithLanguage;
|
var lang = filter.HasNoAudioTrackWithLanguage;
|
||||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
|
var undetermined = IsUndetermined(lang);
|
||||||
|
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
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
||||||
{
|
{
|
||||||
var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
|
var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
|
||||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
|
var undetermined = IsUndetermined(lang);
|
||||||
|
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
|
||||||
|
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||||
|
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||||
|
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
||||||
{
|
{
|
||||||
var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
|
var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
|
||||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
|
var undetermined = IsUndetermined(lang);
|
||||||
|
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
|
||||||
|
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||||
|
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||||
|
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
||||||
{
|
{
|
||||||
var lang = filter.HasNoSubtitleTrackWithLanguage;
|
var lang = filter.HasNoSubtitleTrackWithLanguage;
|
||||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
|
var undetermined = IsUndetermined(lang);
|
||||||
|
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
|
||||||
|
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||||
|
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||||
|
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasSubtitles.HasValue)
|
if (filter.HasSubtitles.HasValue)
|
||||||
{
|
{
|
||||||
var hasSubtitles = filter.HasSubtitles.Value;
|
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)
|
if (hasSubtitles)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.SubtitleLanguages.Count > 0)
|
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
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.Where(e =>
|
||||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
|
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
|
||||||
&& (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
|
&& (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
|
||||||
|
|| versionsWithSubtitles.Contains(e.Id)))
|
||||||
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
|
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.AudioLanguages.Count > 0)
|
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
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.Where(e =>
|
||||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
|
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
|
||||||
&& (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
|
&& (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
|
||||||
|
|| versionsWithAudio.Contains(e.Id)))
|
||||||
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
|
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasChapterImages.HasValue)
|
if (filter.HasChapterImages.HasValue)
|
||||||
{
|
{
|
||||||
var hasChapterImages = filter.HasChapterImages.Value;
|
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)
|
if (hasChapterImages)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e =>
|
.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)));
|
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,17 +102,7 @@ public static class DescendantQueryHelper
|
|||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
ArgumentNullException.ThrowIfNull(criteria);
|
ArgumentNullException.ThrowIfNull(criteria);
|
||||||
|
|
||||||
var matchingItemIds = criteria switch
|
var matchingItemIds = GetItemIdsMatching(context, criteria);
|
||||||
{
|
|
||||||
HasSubtitles => context.MediaStreamInfos
|
|
||||||
.Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle)
|
|
||||||
.Select(ms => ms.ItemId),
|
|
||||||
HasChapterImages => context.Chapters
|
|
||||||
.Where(c => c.ImagePath != null)
|
|
||||||
.Select(c => c.ItemId),
|
|
||||||
HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m),
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}")
|
|
||||||
};
|
|
||||||
|
|
||||||
// One hop up the closure covers every ancestor level.
|
// One hop up the closure covers every ancestor level.
|
||||||
var hierarchyAncestors = context.AncestorIds
|
var hierarchyAncestors = context.AncestorIds
|
||||||
@@ -144,7 +134,63 @@ public static class DescendantQueryHelper
|
|||||||
.Distinct();
|
.Distinct();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IQueryable<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
|
/// <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>
|
||||||
|
/// <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);
|
||||||
|
|
||||||
|
return MatchingMediaOwners(context, criteria)
|
||||||
|
.Where(e => e.PrimaryVersionId.HasValue)
|
||||||
|
.Select(e => e.PrimaryVersionId!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The items whose own media matches, as their BaseItems rows so the version group can be read off
|
||||||
|
// them. One definition of "matches" per criteria, so the projections above cannot drift apart.
|
||||||
|
private static IQueryable<BaseItemEntity> MatchingMediaOwners(JellyfinDbContext context, FolderMatchCriteria criteria)
|
||||||
|
=> criteria switch
|
||||||
|
{
|
||||||
|
HasSubtitles => context.MediaStreamInfos
|
||||||
|
.Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle)
|
||||||
|
.Select(ms => ms.Item),
|
||||||
|
HasChapterImages => context.Chapters
|
||||||
|
.Where(c => c.ImagePath != null)
|
||||||
|
.Select(c => c.Item),
|
||||||
|
HasMediaStreamType m => GetMatchingMediaStreams(context, m).Select(ms => ms.Item),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IQueryable<MediaStreamInfo> GetMatchingMediaStreams(JellyfinDbContext context, HasMediaStreamType criteria)
|
||||||
{
|
{
|
||||||
var query = context.MediaStreamInfos
|
var query = context.MediaStreamInfos
|
||||||
.Where(ms => ms.StreamType == criteria.StreamType
|
.Where(ms => ms.StreamType == criteria.StreamType
|
||||||
@@ -157,7 +203,7 @@ public static class DescendantQueryHelper
|
|||||||
query = query.Where(ms => ms.IsExternal == isExternal);
|
query = query.Where(ms => ms.IsExternal == isExternal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return query.Select(ms => ms.ItemId);
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots)
|
private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots)
|
||||||
|
|||||||
+131
@@ -29,6 +29,12 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture
|
|||||||
private readonly Guid _linkedSeries = Guid.NewGuid();
|
private readonly Guid _linkedSeries = Guid.NewGuid();
|
||||||
private readonly Guid _linkedEpisode = 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();
|
||||||
|
|
||||||
public BaseItemRepositoryStreamFilterTests()
|
public BaseItemRepositoryStreamFilterTests()
|
||||||
{
|
{
|
||||||
using (var ctx = CreateDbContext())
|
using (var ctx = CreateDbContext())
|
||||||
@@ -105,6 +111,74 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture
|
|||||||
Assert.DoesNotContain(_withoutSubtitles, 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 }));
|
||||||
|
}
|
||||||
|
|
||||||
private void Seed(JellyfinDbContext context)
|
private void Seed(JellyfinDbContext context)
|
||||||
{
|
{
|
||||||
context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true });
|
context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true });
|
||||||
@@ -178,6 +252,63 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture
|
|||||||
Item = null!
|
Item = null!
|
||||||
});
|
});
|
||||||
|
|
||||||
|
SeedVersionGroup(context);
|
||||||
|
|
||||||
context.SaveChanges();
|
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!
|
||||||
|
});
|
||||||
|
|
||||||
|
context.Chapters.Add(new Chapter
|
||||||
|
{
|
||||||
|
ItemId = _alternateVersion,
|
||||||
|
ChapterIndex = 0,
|
||||||
|
StartPositionTicks = 0,
|
||||||
|
ImagePath = "/alternate-chapter.jpg",
|
||||||
|
Item = null!
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user