aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-01-31 19:19:26 +0100
committerShadowghost <Ghost_of_Stone@web.de>2026-01-31 19:22:04 +0100
commit2789532aa88ccc899ff8497537642e1d78b31ef5 (patch)
treebc50cc8b536c9487ddaa773f5a411fc2ddd17ac8 /Jellyfin.Server.Implementations
parent694db80d4c8e83ff381af56d2a3dde29e0855c3d (diff)
Optimize Validator and Filter Performance
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs139
-rw-r--r--Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs47
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs3
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs4
4 files changed, 144 insertions, 49 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 22178e57f7..55ef5972d4 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -993,7 +993,7 @@ public sealed class BaseItemRepository
dbQuery = dbQuery.Include(e => e.Extras);
}
- return dbQuery;
+ return dbQuery.AsSingleQuery();
}
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
@@ -1047,6 +1047,62 @@ public sealed class BaseItemRepository
}
/// <inheritdoc />
+ public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+ PrepareFilterQuery(filter);
+
+ using var context = _dbProvider.CreateDbContext();
+ var baseQuery = PrepareItemQuery(context, filter);
+ baseQuery = TranslateQuery(baseQuery, context, filter);
+
+ // Get matching item IDs as a subquery (not materialized)
+ var matchingItemIds = baseQuery.Select(e => e.Id);
+
+ // Query distinct years directly from the database
+ var years = baseQuery
+ .Where(e => e.ProductionYear != null && e.ProductionYear > 0)
+ .Select(e => e.ProductionYear!.Value)
+ .Distinct()
+ .OrderBy(y => y)
+ .ToArray();
+
+ // Query distinct official ratings directly from the database
+ var officialRatings = baseQuery
+ .Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty)
+ .Select(e => e.OfficialRating!)
+ .Distinct()
+ .OrderBy(r => r)
+ .ToArray();
+
+ // Tags via ItemValuesMap JOIN - uses subquery for matching items
+ var tags = context.ItemValuesMap
+ .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags)
+ .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
+ .Select(ivm => ivm.ItemValue.CleanValue)
+ .Distinct()
+ .OrderBy(t => t)
+ .ToArray();
+
+ // Genres via ItemValuesMap JOIN - uses subquery for matching items
+ var genres = context.ItemValuesMap
+ .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre)
+ .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
+ .Select(ivm => ivm.ItemValue.CleanValue)
+ .Distinct()
+ .OrderBy(g => g)
+ .ToArray();
+
+ return new QueryFiltersLegacy
+ {
+ Years = years,
+ OfficialRatings = officialRatings,
+ Tags = tags,
+ Genres = genres
+ };
+ }
+
+ /// <inheritdoc />
public ItemCounts GetItemCounts(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
@@ -1229,8 +1285,6 @@ public sealed class BaseItemRepository
}
}
- context.SaveChanges();
-
var itemValueMaps = tuples
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
.ToArray();
@@ -1255,7 +1309,6 @@ public sealed class BaseItemRepository
Value = f.Value
}).ToArray();
context.ItemValues.AddRange(missingItemValues);
- context.SaveChanges();
var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
var valueMap = itemValueMaps
@@ -1291,8 +1344,6 @@ public sealed class BaseItemRepository
context.ItemValuesMap.RemoveRange(itemMappedValues);
}
- context.SaveChanges();
-
var itemsWithAncestors = tuples
.Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
.Select(t => t.Item.Id)
@@ -1619,7 +1670,8 @@ public sealed class BaseItemRepository
.Include(e => e.LockedFields)
.Include(e => e.UserData)
.Include(e => e.Images)
- .Include(e => e.LinkedChildEntities);
+ .Include(e => e.LinkedChildEntities)
+ .AsSingleQuery();
var item = dbQuery.FirstOrDefault(e => e.Id == id);
if (item is null)
@@ -2780,7 +2832,7 @@ public sealed class BaseItemRepository
if (filter.TrailerTypes.Length > 0)
{
var trailerTypes = filter.TrailerTypes.Select(e => (int)e).ToArray();
- baseQuery = baseQuery.Where(e => trailerTypes.Any(f => e.TrailerTypes!.Any(w => w.Id == f)));
+ baseQuery = baseQuery.Where(e => e.TrailerTypes!.Any(w => trailerTypes.Contains(w.Id)));
}
if (filter.IsAiring.HasValue)
@@ -2896,29 +2948,31 @@ public sealed class BaseItemRepository
if (filter.IsFavoriteOrLiked.HasValue)
{
+ var favoriteItemIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
+ .Select(ud => ud.ItemId);
if (filter.IsFavoriteOrLiked.Value)
{
- baseQuery = baseQuery
- .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite));
+ baseQuery = baseQuery.Where(e => favoriteItemIds.Contains(e.Id));
}
else
{
- baseQuery = baseQuery
- .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite));
+ baseQuery = baseQuery.Where(e => !favoriteItemIds.Contains(e.Id));
}
}
if (filter.IsFavorite.HasValue)
{
+ var favoriteItemIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
+ .Select(ud => ud.ItemId);
if (filter.IsFavorite.Value)
{
- baseQuery = baseQuery
- .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite));
+ baseQuery = baseQuery.Where(e => favoriteItemIds.Contains(e.Id));
}
else
{
- baseQuery = baseQuery
- .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite));
+ baseQuery = baseQuery.Where(e => !favoriteItemIds.Contains(e.Id));
}
}
@@ -2927,44 +2981,56 @@ public sealed class BaseItemRepository
// We should probably figure this out for all folders, but for right now, this is the only place where we need it
if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series)
{
- // Use subquery to find series with played episodes - stays in SQL instead of materializing to HashSet
- var playedSeriesKeysSubquery = context.BaseItems
- .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesPresentationUniqueKey != null)
- .Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Played))
- .Select(e => e.SeriesPresentationUniqueKey!);
+ // Get played series IDs by joining episodes to UserData via SeriesId (Guid foreign key).
+ // Don't filter episodes by TopParentIds here - the series will be filtered by baseQuery anyway.
+ // This allows the materialized list to be reused across library-scoped queries.
+ var playedSeriesIdList = context.BaseItems
+ .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
+ .Join(
+ context.UserData.Where(ud => ud.UserId == filter.User!.Id && ud.Played),
+ episode => episode.Id,
+ ud => ud.ItemId,
+ (episode, ud) => episode.SeriesId!.Value)
+ .Distinct()
+ .ToList();
if (filter.IsPlayed.Value)
{
- baseQuery = baseQuery.Where(e => playedSeriesKeysSubquery.Contains(e.PresentationUniqueKey!));
+ baseQuery = baseQuery.Where(s => playedSeriesIdList.Contains(s.Id));
}
else
{
- baseQuery = baseQuery.Where(e => !playedSeriesKeysSubquery.Contains(e.PresentationUniqueKey!));
+ baseQuery = baseQuery.Where(s => !playedSeriesIdList.Contains(s.Id));
}
}
- else if (filter.IsPlayed.Value)
- {
- baseQuery = baseQuery
- .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played));
- }
else
{
- baseQuery = baseQuery
- .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played));
+ var playedItemIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.Played)
+ .Select(ud => ud.ItemId);
+ if (filter.IsPlayed.Value)
+ {
+ baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id));
+ }
+ else
+ {
+ baseQuery = baseQuery.Where(e => !playedItemIds.Contains(e.Id));
+ }
}
}
if (filter.IsResumable.HasValue)
{
+ var resumableItemIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0)
+ .Select(ud => ud.ItemId);
if (filter.IsResumable.Value)
{
- baseQuery = baseQuery
- .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.PlaybackPositionTicks > 0));
+ baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id));
}
else
{
- baseQuery = baseQuery
- .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.PlaybackPositionTicks > 0));
+ baseQuery = baseQuery.Where(e => !resumableItemIds.Contains(e.Id));
}
}
@@ -3681,9 +3747,12 @@ public sealed class BaseItemRepository
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
+ // GroupBy with a constant key aggregates all rows into a single group for server-side counting.
+ // OrderBy is required before FirstOrDefault to avoid EF Core warnings about unpredictable results.
var result = query
.Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played))
- .GroupBy(_ => 1) // Hack to aggregate over entire set
+ .GroupBy(_ => 1)
+ .OrderBy(g => g.Key)
.Select(g => new
{
Total = g.Count(),
diff --git a/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs b/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs
index d7b2567f37..888dacd16b 100644
--- a/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs
+++ b/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs
@@ -26,13 +26,25 @@ internal static class FolderAwareFilterExtensions
JellyfinDbContext context,
Expression<Func<BaseItemEntity, bool>> condition)
{
- // Use correlated Any() subqueries instead of UNION + Contains for better index utilization
- var matchingIds = context.BaseItems.Where(condition).Select(b => b.Id);
+ // Get IDs of items that directly match the condition
+ var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id);
- return query.Where(e =>
- matchingIds.Contains(e.Id)
- || context.AncestorIds.Any(a => a.ParentItemId == e.Id && matchingIds.Contains(a.ItemId))
- || context.LinkedChildren.Any(lc => lc.ParentId == e.Id && matchingIds.Contains(lc.ChildId)));
+ // Get parent IDs where a descendant (via AncestorIds) matches
+ var ancestorMatchIds = context.AncestorIds
+ .Where(a => directMatchIds.Contains(a.ItemId))
+ .Select(a => a.ParentItemId);
+
+ // Get parent IDs where a linked child matches
+ var linkedMatchIds = context.LinkedChildren
+ .Where(lc => directMatchIds.Contains(lc.ChildId))
+ .Select(lc => lc.ParentId);
+
+ var allMatchingIds = directMatchIds
+ .Concat(ancestorMatchIds)
+ .Concat(linkedMatchIds)
+ .Distinct();
+
+ return query.Where(e => allMatchingIds.Contains(e.Id));
}
/// <summary>
@@ -48,11 +60,24 @@ internal static class FolderAwareFilterExtensions
JellyfinDbContext context,
Expression<Func<BaseItemEntity, bool>> condition)
{
- var matchingIds = context.BaseItems.Where(condition).Select(b => b.Id);
+ // Get IDs of items that directly match the condition
+ var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id);
+
+ // Get parent IDs where a descendant (via AncestorIds) matches
+ var ancestorMatchIds = context.AncestorIds
+ .Where(a => directMatchIds.Contains(a.ItemId))
+ .Select(a => a.ParentItemId);
+
+ // Get parent IDs where a linked child matches
+ var linkedMatchIds = context.LinkedChildren
+ .Where(lc => directMatchIds.Contains(lc.ChildId))
+ .Select(lc => lc.ParentId);
+
+ var allMatchingIds = directMatchIds
+ .Concat(ancestorMatchIds)
+ .Concat(linkedMatchIds)
+ .Distinct();
- return query.Where(e =>
- !matchingIds.Contains(e.Id)
- && !context.AncestorIds.Any(a => a.ParentItemId == e.Id && matchingIds.Contains(a.ItemId))
- && !context.LinkedChildren.Any(lc => lc.ParentId == e.Id && matchingIds.Contains(lc.ChildId)));
+ return query.Where(e => !allMatchingIds.Contains(e.Id));
}
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 355ed64797..022b452e85 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -62,10 +62,9 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
- // dbQuery = dbQuery.OrderBy(e => e.ListOrder);
if (filter.Limit > 0)
{
- dbQuery = dbQuery.Take(filter.Limit);
+ dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
}
return dbQuery.ToArray();
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 501cb4fbe8..7292e9c7a9 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -93,7 +93,7 @@ namespace Jellyfin.Server.Implementations.Users
_users = new ConcurrentDictionary<Guid, User>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var user in dbContext.Users
- .AsSplitQuery()
+ .AsSingleQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
@@ -607,6 +607,7 @@ namespace Jellyfin.Server.Implementations.Users
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
+ .AsSingleQuery()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
@@ -651,6 +652,7 @@ namespace Jellyfin.Server.Implementations.Users
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
+ .AsSingleQuery()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");