aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations/Item
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations/Item')
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs64
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs183
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs153
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs12
4 files changed, 338 insertions, 74 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index c7acf72043..c9e08b1b5d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -110,7 +110,7 @@ public sealed partial class BaseItemRepository
PrepareFilterQuery(filter);
// Early exit if collection type is not supported
- if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
+ if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown)
{
return [];
}
@@ -121,30 +121,27 @@ public sealed partial class BaseItemRepository
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
- if (collectionType == CollectionType.tvshows)
+ if (collectionType is CollectionType.tvshows)
{
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
if (collectionType is CollectionType.movies)
{
- // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
- // Build up until limit by streaming through results and deduplicating on the fly.
- var orderedIds = baseQuery
- .Where(e => e.PresentationUniqueKey != null)
- .OrderByDescending(e => e.DateCreated)
- .ThenByDescending(e => e.Id)
- .Select(e => new { e.Id, e.PresentationUniqueKey });
-
- // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
- var firstIds = orderedIds
- .AsEnumerable()
- .DistinctBy(row => row.PresentationUniqueKey)
- .Select(row => row.Id)
+ return GetLatestMovieItems(context, baseQuery, filter, limit);
+ }
+
+ if (collectionType is CollectionType.unknown)
+ {
+ var moviesQuery = baseQuery.Where(e => e.SeriesName == null);
+ var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit);
+ var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit);
+
+ return latestMovies.Concat(latestShows)
+ .OrderByDescending(dto => dto.DateCreated)
+ .ThenByDescending(dto => dto.Id)
.Take(limit ?? int.MaxValue)
.ToList();
-
- return LoadLatestByIds(context, firstIds, filter);
}
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
@@ -226,6 +223,39 @@ public sealed partial class BaseItemRepository
}
/// <summary>
+ /// Gets the latest movies, deduplicated so each movie only appears once.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to pull movies from, with filters already applied.</param>
+ /// <param name="filter">The original query filter, used when loading the final items.</param>
+ /// <param name="limit">How many items to return.</param>
+ /// <returns>The latest movies, newest first.</returns>
+ private IReadOnlyList<BaseItemDto> GetLatestMovieItems(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter,
+ int? limit)
+ {
+ // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
+ // Build up until limit by streaming through results and deduplicating on the fly.
+ var orderedIds = baseQuery
+ .Where(e => e.PresentationUniqueKey != null)
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey });
+
+ // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
+ var firstIds = orderedIds
+ .AsEnumerable()
+ .DistinctBy(row => row.PresentationUniqueKey)
+ .Select(row => row.Id)
+ .Take(limit ?? int.MaxValue)
+ .ToList();
+
+ return LoadLatestByIds(context, firstIds, filter);
+ }
+
+ /// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
/// <remarks>
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/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index fd683fb57e..c42b5f9581 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}
/// <inheritdoc/>
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}
@@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
+ private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
+ {
+ var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
+
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(descendantIds, b => b.Id)
+ .Where(DescendantQueryHelper.IsCountableLeaf);
+
+ return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
+ }
+
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
@@ -294,9 +309,9 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
+ var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
var baseQuery = dbContext.BaseItems
- .Where(b => allDescendantIds.Contains(b.Id))
+ .WhereOneOrMany(allDescendantIds, b => b.Id)
.Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
@@ -318,20 +333,29 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var linkedCounts = dbContext.LinkedChildren
- .Where(lc => parentIdsArray.Contains(lc.ParentId))
+ .WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
+
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
{
+ if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
+ {
+ result[parentId] = mergedCount;
+ continue;
+ }
+
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
@@ -341,6 +365,50 @@ public class ItemCountService : IItemCountService
return result;
}
+ private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
+ {
+ var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
+ .Where(group => group.Value.Count > 1)
+ .ToArray();
+
+ if (mergedGroups.Length == 0)
+ {
+ return [];
+ }
+
+ // Only merged folders.
+ var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
+ var children = dbContext.BaseItems
+ .AsNoTracking()
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(memberIds, b => b.ParentId!.Value)
+ .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
+ .ToArray()
+ .GroupBy(b => b.ParentId)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
+ ? b.Id.ToString("N", CultureInfo.InvariantCulture)
+ : b.PresentationUniqueKey).ToArray());
+
+ var result = new Dictionary<Guid, int>();
+ foreach (var (parentId, members) in mergedGroups)
+ {
+ var childKeys = new HashSet<string>(StringComparer.Ordinal);
+ foreach (var member in members)
+ {
+ if (children.TryGetValue(member, out var keys))
+ {
+ childKeys.UnionWith(keys);
+ }
+ }
+
+ result[parentId] = childKeys.Count;
+ }
+
+ return result;
+ }
+
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
@@ -353,10 +421,13 @@ public class ItemCountService : IItemCountService
}
using var dbContext = _dbProvider.CreateDbContext();
- var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;
+ // Merged series and seasons are stored as one row per folder-item sharing a presentation key.
+ var groups = GetPresentationKeyGroups(dbContext, folderIds);
+ var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
+
var leafItems = dbContext.BaseItems
.Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
@@ -398,7 +469,7 @@ public class ItemCountService : IItemCountService
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
- var results = ancestorLeaves
+ var countsByFolder = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
@@ -410,9 +481,73 @@ public class ItemCountService : IItemCountService
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
+ var results = new Dictionary<Guid, (int Played, int Total)>();
+ foreach (var (folderId, members) in groups)
+ {
+ var played = 0;
+ var total = 0;
+
+ // Members of a group are distinct folders, so their leaves cannot overlap.
+ foreach (var member in members)
+ {
+ if (countsByFolder.TryGetValue(member, out var counts))
+ {
+ played += counts.Played;
+ total += counts.Total;
+ }
+ }
+
+ if (total > 0 || played > 0)
+ {
+ results[folderId] = (played, total);
+ }
+ }
+
return results;
}
+ private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds)
+ {
+ var requested = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(folderIds, e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey })
+ .ToArray();
+
+ var keys = requested
+ .Select(e => e.PresentationUniqueKey)
+ .Where(key => !string.IsNullOrEmpty(key))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ // Every item that is not merged carries a key derived from its own id, so in the common case
+ // each group resolves back to the single folder that was asked for.
+ var membersByKey = keys.Length == 0
+ ? []
+ : dbContext.BaseItems
+ .AsNoTracking()
+ .Where(e => e.IsFolder)
+ .WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
+ .Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
+ .ToArray()
+ .GroupBy(e => e.Key, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
+
+ var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
+ var groups = new Dictionary<Guid, List<Guid>>();
+ foreach (var folderId in folderIds)
+ {
+ groups[folderId] = keyById.TryGetValue(folderId, out var key)
+ && !string.IsNullOrEmpty(key)
+ && membersByKey.TryGetValue(key, out var members)
+ && members.Count > 0
+ ? members
+ : [folderId];
+ }
+
+ return groups;
+ }
+
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index a592d0e6e2..aaa363b046 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -351,7 +351,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (!filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
+ var itemId = filter.ItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(itemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
if (filter.ParentId != null)
@@ -361,7 +365,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (!filter.AppearsInItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
+ var appearsInItemId = filter.AppearsInItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(appearsInItemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();