Merge pull request #17422 from Shadowghost/performance
Reduce correlated subqueries to improve query performance
This commit is contained in:
@@ -1088,7 +1088,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
dto.ParentId = item.DisplayParentId;
|
||||
}
|
||||
|
||||
AddInheritedImages(dto, item, options, owner);
|
||||
AddInheritedImages(dto, item, options, owner, artistsBatch);
|
||||
|
||||
if (options.ContainsField(ItemFields.Path))
|
||||
{
|
||||
@@ -1519,11 +1519,11 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (currentItem is MusicAlbum musicAlbum)
|
||||
{
|
||||
var artist = musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
if (artist is not null)
|
||||
{
|
||||
return artist;
|
||||
@@ -1540,7 +1540,20 @@ namespace Emby.Server.Implementations.Dto
|
||||
return parent;
|
||||
}
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
|
||||
private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (artistsBatch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null;
|
||||
return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? artists[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
|
||||
&& options.GetImageLimit(ImageType.Primary) > 0
|
||||
@@ -1585,7 +1598,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|
||||
|| parent is Series)
|
||||
{
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent;
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent;
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
@@ -1644,7 +1657,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
break;
|
||||
}
|
||||
|
||||
parent = GetImageDisplayParent(parent, item);
|
||||
parent = GetImageDisplayParent(parent, item, artistsBatch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Dto;
|
||||
@@ -109,7 +110,6 @@ public sealed partial class BaseItemRepository
|
||||
IsNews = filter.IsNews,
|
||||
IsSeries = filter.IsSeries
|
||||
})
|
||||
.Where(e => e.MediaStreams != null)
|
||||
.SelectMany(e => e.MediaStreams!)
|
||||
.Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType)
|
||||
.Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined
|
||||
@@ -168,21 +168,16 @@ public sealed partial class BaseItemRepository
|
||||
IsSeries = filter.IsSeries
|
||||
});
|
||||
|
||||
// Keep this as an IQueryable sub-select. Materializing to a list would inline one
|
||||
// bound parameter per CleanValue and hit SQLite's variable cap on libraries with
|
||||
// high-cardinality value types (e.g. tens of thousands of artists).
|
||||
var matchingCleanValues = context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Join(
|
||||
innerQueryFilter,
|
||||
ivm => ivm.ItemId,
|
||||
g => g.Id,
|
||||
(ivm, g) => ivm.ItemValue.CleanValue)
|
||||
.Distinct();
|
||||
|
||||
var innerQuery = PrepareItemQuery(context, filter)
|
||||
.Where(e => e.Type == returnType)
|
||||
.Where(e => matchingCleanValues.Contains(e.CleanName!));
|
||||
.Where(e => context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName)
|
||||
.Join(
|
||||
innerQueryFilter,
|
||||
ivm => ivm.ItemId,
|
||||
g => g.Id,
|
||||
(ivm, g) => ivm.ItemId)
|
||||
.Any());
|
||||
|
||||
var outerQueryFilter = new InternalItemsQuery(filter.User)
|
||||
{
|
||||
@@ -205,32 +200,42 @@ public sealed partial class BaseItemRepository
|
||||
ExcludeItemIds = filter.ExcludeItemIds
|
||||
};
|
||||
|
||||
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
|
||||
// the lowest Id per group. For MusicArtist, prefer the entity from a library the user
|
||||
// can actually access,since the same artist can have a folder in multiple libraries.
|
||||
// Keep as an IQueryable sub-select so paging is applied AFTER
|
||||
// ApplyOrder runs the caller's actual sort.
|
||||
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one
|
||||
// representative id per group, then materialize the representative ids once.
|
||||
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
|
||||
var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
|
||||
var representativeIds = isMusicArtist
|
||||
? masterQuery
|
||||
List<Guid> representativeIds;
|
||||
if (isMusicArtist)
|
||||
{
|
||||
// For MusicArtist, prefer the entity from a library the user can actually access.
|
||||
// Materialize to prevent correlated per-group first-row queries which hurt performance.
|
||||
var topParentIds = filter.TopParentIds;
|
||||
representativeIds = masterQuery
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId })
|
||||
.AsEnumerable()
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g
|
||||
.OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
|
||||
.OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
|
||||
.ThenBy(e => e.Id)
|
||||
.First().Id)
|
||||
: masterQuery
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
representativeIds = masterQuery
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
.Select(g => g.Min(e => e.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
result.TotalRecordCount = representativeIds.Count();
|
||||
result.TotalRecordCount = representativeIds.Count;
|
||||
}
|
||||
|
||||
var query = ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
|
||||
context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id),
|
||||
filter);
|
||||
|
||||
query = ApplyOrder(query, filter, context);
|
||||
@@ -311,8 +316,8 @@ public sealed partial class BaseItemRepository
|
||||
var itemIds = itemCountQuery.Select(e => e.Id);
|
||||
|
||||
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
|
||||
// Instead, start from ItemValueMaps and join with BaseItems
|
||||
return context.ItemValuesMap
|
||||
// Instead, start from ItemValueMaps and join with BaseItems.
|
||||
var rawCounts = context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Where(ivm => itemIds.Contains(ivm.ItemId))
|
||||
.Join(
|
||||
@@ -322,18 +327,47 @@ public sealed partial class BaseItemRepository
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
|
||||
.GroupBy(x => new { x.CleanName, x.Type })
|
||||
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
|
||||
.GroupBy(x => x.CleanName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new ItemCounts
|
||||
.AsEnumerable();
|
||||
|
||||
var countsByCleanName = new Dictionary<string, ItemCounts>();
|
||||
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
|
||||
{
|
||||
var counts = new ItemCounts();
|
||||
foreach (var row in group)
|
||||
{
|
||||
if (row.Type == seriesTypeName)
|
||||
{
|
||||
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
|
||||
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
|
||||
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
|
||||
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
|
||||
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
|
||||
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
|
||||
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
|
||||
});
|
||||
counts.SeriesCount += row.Count;
|
||||
}
|
||||
else if (row.Type == episodeTypeName)
|
||||
{
|
||||
counts.EpisodeCount += row.Count;
|
||||
}
|
||||
else if (row.Type == movieTypeName)
|
||||
{
|
||||
counts.MovieCount += row.Count;
|
||||
}
|
||||
else if (row.Type == musicAlbumTypeName)
|
||||
{
|
||||
counts.AlbumCount += row.Count;
|
||||
}
|
||||
else if (row.Type == musicArtistTypeName)
|
||||
{
|
||||
counts.ArtistCount += row.Count;
|
||||
}
|
||||
else if (row.Type == audioTypeName)
|
||||
{
|
||||
counts.SongCount += row.Count;
|
||||
}
|
||||
else if (row.Type == trailerTypeName)
|
||||
{
|
||||
counts.TrailerCount += row.Count;
|
||||
}
|
||||
}
|
||||
|
||||
countsByCleanName[group.Key] = counts;
|
||||
}
|
||||
|
||||
return countsByCleanName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,38 +126,54 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (collectionType is CollectionType.movies)
|
||||
{
|
||||
// Group by PresentationUniqueKey, pick the newest item per group.
|
||||
var topGroupItems = baseQuery
|
||||
// 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)
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => new
|
||||
{
|
||||
MaxDate = g.Max(e => e.DateCreated),
|
||||
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
|
||||
})
|
||||
.OrderByDescending(g => g.MaxDate);
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey });
|
||||
|
||||
var firstIdsQuery = filter.Limit.HasValue
|
||||
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
|
||||
: topGroupItems.Select(g => g.FirstId);
|
||||
// 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, firstIdsQuery, filter);
|
||||
return LoadLatestByIds(context, firstIds, filter);
|
||||
}
|
||||
|
||||
// Albums whose Id is the parent of any track matching the user's filter.
|
||||
var albumIdsWithMatchingTrack = context.AncestorIds
|
||||
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
|
||||
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
|
||||
var topAlbumsQuery = context.BaseItems.AsNoTracking()
|
||||
.Where(album => album.Type == musicAlbumTypeName)
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id))
|
||||
IQueryable<BaseItemEntity> topAlbumsQuery;
|
||||
|
||||
// When the query is scoped to whole libraries, read the newest albums directly by their own TopParentId.
|
||||
if (filter.TopParentIds.Length > 0)
|
||||
{
|
||||
topAlbumsQuery = context.BaseItems.AsNoTracking()
|
||||
.Where(album => album.Type == musicAlbumTypeName
|
||||
&& !album.IsVirtualItem
|
||||
&& album.TopParentId.HasValue)
|
||||
.WhereOneOrMany(filter.TopParentIds, album => album.TopParentId!.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback (e.g. AncestorIds-scoped callers): albums that are the parent of a matching track.
|
||||
var albumIdsWithMatchingTrack = context.AncestorIds
|
||||
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
|
||||
topAlbumsQuery = context.BaseItems.AsNoTracking()
|
||||
.Where(album => album.Type == musicAlbumTypeName)
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
|
||||
}
|
||||
|
||||
var orderedAlbums = topAlbumsQuery
|
||||
.OrderByDescending(album => album.DateCreated)
|
||||
.ThenByDescending(album => album.Id);
|
||||
|
||||
var albumIdsQuery = filter.Limit.HasValue
|
||||
? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id)
|
||||
: topAlbumsQuery.Select(a => a.Id);
|
||||
var albumIdsQuery = limit.HasValue
|
||||
? orderedAlbums.Take(limit.Value).Select(a => a.Id)
|
||||
: orderedAlbums.Select(a => a.Id);
|
||||
|
||||
return LoadLatestByIds(context, albumIdsQuery, filter);
|
||||
}
|
||||
@@ -181,6 +197,29 @@ public sealed partial class BaseItemRepository
|
||||
.ToArray()!;
|
||||
}
|
||||
|
||||
private IReadOnlyList<BaseItemDto> LoadLatestByIds(
|
||||
JellyfinDbContext context,
|
||||
List<Guid> ids,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var itemsQuery = ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().WhereOneOrMany(ids, e => e.Id),
|
||||
filter);
|
||||
|
||||
return itemsQuery
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.AsEnumerable()
|
||||
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||
.Where(dto => dto != null)
|
||||
.ToArray()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest TV show items with smart Season/Series container selection.
|
||||
/// </summary>
|
||||
|
||||
@@ -434,20 +434,34 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.IsLiked.HasValue)
|
||||
{
|
||||
var isLiked = filter.IsLiked.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) == isLiked);
|
||||
var likedIds = context.UserData
|
||||
.Where(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
baseQuery = filter.IsLiked.Value
|
||||
? baseQuery.Where(e => likedIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !likedIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsFavoriteOrLiked.HasValue)
|
||||
if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue)
|
||||
{
|
||||
var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked);
|
||||
}
|
||||
var favoriteIds = context.UserData
|
||||
.Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
if (filter.IsFavorite.HasValue)
|
||||
{
|
||||
var isFavorite = filter.IsFavorite.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite);
|
||||
if (filter.IsFavoriteOrLiked.HasValue)
|
||||
{
|
||||
baseQuery = filter.IsFavoriteOrLiked.Value
|
||||
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsFavorite.HasValue)
|
||||
{
|
||||
baseQuery = filter.IsFavorite.Value
|
||||
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.IsPlayed.HasValue)
|
||||
@@ -560,16 +574,19 @@ public sealed partial class BaseItemRepository
|
||||
// Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
|
||||
// which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
|
||||
// the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
|
||||
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
|
||||
.Where(s => s.Id != e.Id
|
||||
&& inProgressIds.Contains(s.Id)
|
||||
&& (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
|
||||
.Any(s =>
|
||||
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
&& s.Id.CompareTo(e.Id) < 0)));
|
||||
// Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those.
|
||||
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName
|
||||
|| (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id))
|
||||
|| !context.BaseItems
|
||||
.Where(s => s.Id != e.Id
|
||||
&& inProgressIds.Contains(s.Id)
|
||||
&& (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
|
||||
.Any(s =>
|
||||
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
&& s.Id.CompareTo(e.Id) < 0)));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -98,7 +98,7 @@ public class NextUpService : INextUpService
|
||||
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
|
||||
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
|
||||
|
||||
// Use lightweight projection + client-side grouping to avoid correlated scalar subquery
|
||||
// Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
|
||||
// per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault.
|
||||
var allPlayedLite = lastWatchedBase
|
||||
.Select(e => new
|
||||
@@ -110,15 +110,11 @@ public class NextUpService : INextUpService
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var lastWatchedInfo = new Dictionary<string, Guid>();
|
||||
foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey))
|
||||
{
|
||||
var lastWatched = group
|
||||
.OrderByDescending(e => e.ParentIndexNumber)
|
||||
.ThenByDescending(e => e.IndexNumber)
|
||||
.First();
|
||||
lastWatchedInfo[group.Key!] = lastWatched.Id;
|
||||
}
|
||||
var lastWatchedInfo = allPlayedLite
|
||||
.OrderByDescending(e => e.ParentIndexNumber)
|
||||
.ThenByDescending(e => e.IndexNumber)
|
||||
.DistinctBy(e => e.SeriesPresentationUniqueKey)
|
||||
.ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id);
|
||||
|
||||
Dictionary<string, Guid> lastWatchedByDateInfo = new();
|
||||
if (includeWatchedForRewatching)
|
||||
@@ -144,11 +140,10 @@ public class NextUpService : INextUpService
|
||||
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
|
||||
.ToList();
|
||||
|
||||
foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
|
||||
{
|
||||
var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First();
|
||||
lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId;
|
||||
}
|
||||
lastWatchedByDateInfo = playedWithDates
|
||||
.OrderByDescending(x => x.LastPlayedDate)
|
||||
.DistinctBy(x => x.SeriesPresentationUniqueKey)
|
||||
.ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId);
|
||||
}
|
||||
|
||||
var allLastWatchedIds = lastWatchedInfo.Values
|
||||
|
||||
@@ -29,19 +29,30 @@ public static class OrderMapper
|
||||
/// <returns>Func to be executed later for sorting query.</returns>
|
||||
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
|
||||
{
|
||||
if (sortBy == ItemSortBy.DatePlayed)
|
||||
{
|
||||
// An item's played date is the newest of its own progress and that of its alternate versions,
|
||||
// which track progress under their own ids. Matching both in one predicate ORs them together,
|
||||
// which no index can serve: the user's whole UserData table gets scanned per sorted row.
|
||||
// Two indexed lookups combined by MAX cost a seek each instead.
|
||||
var userData = query.User is null
|
||||
? jellyfinDbContext.UserData
|
||||
: jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id);
|
||||
|
||||
return e => userData
|
||||
.Where(w => w.ItemId == e.Id)
|
||||
.Select(w => w.LastPlayedDate)
|
||||
.Concat(userData
|
||||
.Where(w => w.Item!.PrimaryVersionId == e.Id)
|
||||
.Select(w => w.LastPlayedDate))
|
||||
.Max();
|
||||
}
|
||||
|
||||
return (sortBy, query.User) switch
|
||||
{
|
||||
(ItemSortBy.AirTime, _) => e => e.SortName,
|
||||
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
|
||||
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
|
||||
(ItemSortBy.DatePlayed, not null) => e =>
|
||||
jellyfinDbContext.UserData
|
||||
.Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
|
||||
.Max(f => f.LastPlayedDate),
|
||||
(ItemSortBy.DatePlayed, null) => e =>
|
||||
jellyfinDbContext.UserData
|
||||
.Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
|
||||
.Max(f => f.LastPlayedDate),
|
||||
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
|
||||
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
|
||||
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
|
||||
|
||||
@@ -79,7 +79,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
|
||||
|
||||
IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter)
|
||||
.Select(e => e.Name)
|
||||
.Distinct()
|
||||
.OrderBy(e => e);
|
||||
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
|
||||
{
|
||||
@@ -88,7 +92,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
|
||||
if (filter.Limit > 0)
|
||||
{
|
||||
dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
|
||||
dbQuery = dbQuery.Take(filter.Limit);
|
||||
}
|
||||
|
||||
return dbQuery.ToArray();
|
||||
|
||||
+40
-23
@@ -70,15 +70,24 @@ public static class JellyfinQueryHelperExtensions
|
||||
bool invert = false)
|
||||
{
|
||||
var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
|
||||
var typeFilter = OneOrManyExpressionBuilder<ItemValue, ItemValueType>(itemValueTypes, iv => iv.Type);
|
||||
var typeFilter = OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type);
|
||||
|
||||
return baseQuery.Where(item =>
|
||||
context.ItemValues
|
||||
.Where(typeFilter)
|
||||
.Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map })
|
||||
.Any(val =>
|
||||
context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue)
|
||||
&& val.map.ItemId == item.Id) == EF.Constant(!invert));
|
||||
// Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)).
|
||||
var referencedCleanValues = context.BaseItems
|
||||
.Where(itemFilter)
|
||||
.Select(e => e.CleanName);
|
||||
|
||||
var matchingItemIds = context.ItemValuesMap
|
||||
.Where(typeFilter)
|
||||
.Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue))
|
||||
.Select(m => m.ItemId);
|
||||
|
||||
if (invert)
|
||||
{
|
||||
return baseQuery.Where(e => !matchingItemIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
return baseQuery.Where(e => matchingItemIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,13 +111,21 @@ public static class JellyfinQueryHelperExtensions
|
||||
|
||||
var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
|
||||
|
||||
return item =>
|
||||
context.ItemValues
|
||||
.Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (item, map) => new { item, map })
|
||||
.Any(val =>
|
||||
val.item.Type == itemValueType
|
||||
&& context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.item.CleanValue)
|
||||
&& val.map.ItemId == item.Id) == EF.Constant(!invert);
|
||||
// Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)).
|
||||
var referencedCleanValues = context.BaseItems
|
||||
.Where(itemFilter)
|
||||
.Select(e => e.CleanName);
|
||||
|
||||
var matchingItemIds = context.ItemValuesMap
|
||||
.Where(m => m.ItemValue.Type == itemValueType && referencedCleanValues.Contains(m.ItemValue.CleanValue))
|
||||
.Select(m => m.ItemId);
|
||||
|
||||
if (invert)
|
||||
{
|
||||
return item => !matchingItemIds.Contains(item.Id);
|
||||
}
|
||||
|
||||
return item => matchingItemIds.Contains(item.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -224,14 +241,14 @@ public static class JellyfinQueryHelperExtensions
|
||||
|
||||
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
|
||||
|
||||
// Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a
|
||||
// parameterized array lookup by ~5-10% up to ~32 elements.
|
||||
if (oneOf.Count <= 32)
|
||||
{
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter);
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter);
|
||||
// Always wrap the collection in EF.Parameter so EF Core caches a single compiled plan and reuses it across calls.
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
Expression.Call(
|
||||
null,
|
||||
containsMethodInfo,
|
||||
Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)),
|
||||
property.Body),
|
||||
parameter);
|
||||
}
|
||||
|
||||
internal static class ParameterReplacer
|
||||
|
||||
+5
@@ -61,6 +61,11 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
|
||||
builder.HasIndex(e => new { e.TopParentId, e.MediaType, e.IsVirtualItem, e.DateCreated });
|
||||
// resume
|
||||
builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey });
|
||||
// alternate versions of an item, e.g. resolving the played date of a version onto its primary.
|
||||
// Filtered: almost no item has a primary version, and an index covering those rows too would tempt
|
||||
// the planner into serving "PrimaryVersionId IS NULL" - true for the whole library - out of it.
|
||||
builder.HasIndex(e => e.PrimaryVersionId)
|
||||
.HasFilter("\"PrimaryVersionId\" IS NOT NULL");
|
||||
// sorted library queries (e.g., Series sorted by SortName)
|
||||
builder.HasIndex(e => new { e.Type, e.TopParentId, e.SortName });
|
||||
// NextUp: per-series episode ordering (index seek + range scan on season/episode)
|
||||
|
||||
+1813
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrimaryVersionIdIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_PrimaryVersionId",
|
||||
table: "BaseItems",
|
||||
column: "PrimaryVersionId",
|
||||
filter: "\"PrimaryVersionId\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_PrimaryVersionId",
|
||||
table: "BaseItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
||||
{
|
||||
@@ -373,6 +373,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasIndex("PresentationUniqueKey");
|
||||
|
||||
b.HasIndex("PrimaryVersionId")
|
||||
.HasFilter("\"PrimaryVersionId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Emby.Server.Implementations.Dto;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -7,9 +8,9 @@ using MediaBrowser.Controller.Chapters;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -99,9 +100,72 @@ public class DtoServiceImageInheritanceTests
|
||||
Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDtos_MusicAlbums_ResolveInheritedThumbFromArtistBatch_WithoutPerAlbumLookup()
|
||||
{
|
||||
var artist = new MusicArtist
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Some Artist",
|
||||
ImageInfos =
|
||||
[
|
||||
new ItemImageInfo
|
||||
{
|
||||
Type = ImageType.Thumb,
|
||||
Path = "/images/artist-thumb.jpg",
|
||||
DateModified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
static MusicAlbum MakeAlbum() => new MusicAlbum
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Album",
|
||||
AlbumArtists = ["Some Artist"],
|
||||
ImageInfos = []
|
||||
};
|
||||
|
||||
var libraryManager = new Mock<ILibraryManager>();
|
||||
|
||||
// DtoService resolves every album-artist name in ONE batch (GetArtists). The album's inherited
|
||||
// Thumb/Backdrop images must come from that batch, not a per-album GetArtist/GetItemList lookup
|
||||
// (the N+1). GetArtist is intentionally left unset: a regression to the per-album path would
|
||||
// resolve no artist and fail the assertions below.
|
||||
libraryManager
|
||||
.Setup(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()))
|
||||
.Returns(new Dictionary<string, MusicArtist[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Some Artist"] = [artist]
|
||||
});
|
||||
|
||||
var dtoService = BuildDtoService(libraryManager);
|
||||
|
||||
var dtos = dtoService.GetBaseItemDtos([MakeAlbum(), MakeAlbum()], new DtoOptions(false));
|
||||
|
||||
Assert.Equal(2, dtos.Count);
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
Assert.Equal(artist.Id, dto.ParentThumbItemId);
|
||||
Assert.Equal("/images/artist-thumb.jpg", dto.ParentThumbImageTag);
|
||||
}
|
||||
|
||||
// The artist lookup is batched once for the whole set, never once per album.
|
||||
libraryManager.Verify(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()), Times.Once);
|
||||
libraryManager.Verify(x => x.GetArtist(It.IsAny<string>(), It.IsAny<DtoOptions>()), Times.Never);
|
||||
}
|
||||
|
||||
private static DtoService BuildDtoService(BaseItem displayParent)
|
||||
{
|
||||
var libraryManager = new Mock<ILibraryManager>();
|
||||
libraryManager
|
||||
.Setup(x => x.GetItemById(displayParent.Id))
|
||||
.Returns(displayParent);
|
||||
return BuildDtoService(libraryManager);
|
||||
}
|
||||
|
||||
private static DtoService BuildDtoService(Mock<ILibraryManager> libraryManager)
|
||||
{
|
||||
var userDataManager = new Mock<IUserDataManager>();
|
||||
var imageProcessor = new Mock<IImageProcessor>();
|
||||
var providerManager = new Mock<IProviderManager>();
|
||||
@@ -113,10 +177,6 @@ public class DtoServiceImageInheritanceTests
|
||||
var chapterManager = new Mock<IChapterManager>();
|
||||
var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
|
||||
|
||||
libraryManager
|
||||
.Setup(x => x.GetItemById(displayParent.Id))
|
||||
.Returns(displayParent);
|
||||
|
||||
imageProcessor
|
||||
.Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
|
||||
.Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
|
||||
|
||||
Reference in New Issue
Block a user