diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-05-16 16:11:13 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-05-16 16:14:18 +0200 |
| commit | 3655b4b09449e572826fa2f91a88f3b6dd4e63c4 (patch) | |
| tree | ae81d6182cb15f6f84a243e78f890b77ac457174 /Emby.Server.Implementations/Library/SimilarItems | |
| parent | 1fdf58e40f7c8f58377be3716368720923d8d8c0 (diff) | |
Apply review and sonar suggestions
Diffstat (limited to 'Emby.Server.Implementations/Library/SimilarItems')
| -rw-r--r-- | Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs | 312 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs | 11 |
2 files changed, 165 insertions, 158 deletions
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 54466a6ad9..29cde6a570 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -30,6 +30,10 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie private const int DirectorWeight = 50; private const int ActorWeight = 15; + // Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id + // load, navigation includes) stay bounded regardless of caller input. + private const int MaxBatchSourceItems = 64; + private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions = [ (ItemValueType.Genre, GenreWeight), @@ -72,14 +76,14 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie /// <inheritdoc/> public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) { - var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); return results.TryGetValue(item.Id, out var items) ? items : []; } /// <inheritdoc/> public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) { - var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); return results.TryGetValue(item.Id, out var items) ? items : []; } @@ -95,9 +99,10 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie }; /// <inheritdoc/> - public Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync( + public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync( IReadOnlyList<BaseItemDto> sourceItems, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) @@ -109,108 +114,119 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie var limit = query.Limit ?? 50; var dtoOptions = query.DtoOptions ?? new DtoOptions(); - using var context = _dbProvider.CreateDbContext(); - - // Phase 1: Score all candidates per source item - var sourceIds = sourceItems.Select(i => i.Id).ToList(); - var perSourceScores = ComputeBatchScores(sourceIds, context); - - var allCandidateIds = new HashSet<Guid>(); - foreach (var (_, scores) in perSourceScores) + if (sourceItems.Count > MaxBatchSourceItems) { - allCandidateIds.UnionWith( - scores.OrderByDescending(kvp => kvp.Value) - .Take(limit * 3) - .Select(kvp => kvp.Key)); + sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList(); } - var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>(); - if (allCandidateIds.Count == 0) + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) { - return Task.FromResult(result); - } + // Phase 1: Score all candidates per source item + var sourceIds = sourceItems.Select(i => i.Id).ToList(); + var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false); - // Phase 2: One access filter for all candidates - var filter = new InternalItemsQuery(query.User) - { - IncludeItemTypes = [.. includeItemTypes], - ExcludeItemIds = [.. query.ExcludeItemIds], - DtoOptions = dtoOptions, - EnableGroupByMetadataKey = true, - EnableTotalRecordCount = false, - IsMovie = true, - IsPlayed = false - }; + var allCandidateIds = new HashSet<Guid>(); + foreach (var (_, scores) in perSourceScores) + { + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); + } - _queryHelpers.PrepareFilterQuery(filter); - var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); - baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>(); + if (allCandidateIds.Count == 0) + { + return result; + } - var allCandidateIdsList = allCandidateIds.ToList(); - var accessibleItems = baseQuery - .Where(e => allCandidateIdsList.Contains(e.Id)) - .Select(e => new { e.Id, e.PresentationUniqueKey }) - .ToList(); + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) + { + IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + IsMovie = true, + IsPlayed = false + }; + + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = await baseQuery + .WhereOneOrMany(allCandidateIdsList, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet<Guid>(); + var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } - // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey - var allOrderedIds = new HashSet<Guid>(); - var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>(); + var orderedIds = accessibleItems + .Where(x => scores.ContainsKey(x.Id)) + .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + .DistinctBy(x => x.PresentationUniqueKey) + .Take(limit) + .Select(x => x.Id) + .ToList(); - foreach (var item in sourceItems) - { - if (!perSourceScores.TryGetValue(item.Id, out var scores)) - { - continue; + if (orderedIds.Count > 0) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } } - var orderedIds = accessibleItems - .Where(x => scores.ContainsKey(x.Id)) - .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) - .DistinctBy(x => x.PresentationUniqueKey) - .Take(limit) - .Select(x => x.Id) - .ToList(); - - if (orderedIds.Count > 0) + if (allOrderedIds.Count == 0) { - perSourceOrderedIds[item.Id] = orderedIds; - allOrderedIds.UnionWith(orderedIds); + return result; } - } - if (allOrderedIds.Count == 0) - { - return Task.FromResult(result); - } - - // Phase 4: One entity load for all results - var allOrderedIdsList = allOrderedIds.ToList(); - var entitiesById = _queryHelpers.ApplyNavigations( - context.BaseItems.AsNoTracking().Where(e => allOrderedIdsList.Contains(e.Id)), - filter) - .AsEnumerable() - .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) - .Where(dto => dto is not null) - .ToDictionary(i => i!.Id); - - // Phase 5: Split by source, preserving score order - foreach (var (sourceId, orderedIds) in perSourceOrderedIds) - { - var items = orderedIds - .Where(entitiesById.ContainsKey) - .Select(id => entitiesById[id]!) - .ToList(); - - if (items.Count > 0) + // Phase 4: One entity load for all results. AsSplitQuery avoids a SQL Cartesian + // product across the multiple collection Includes added by ApplyNavigations. + var allOrderedIdsList = allOrderedIds.ToList(); + var entities = await _queryHelpers.ApplyNavigations( + context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id), + filter) + .AsSplitQuery() + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var entitiesById = entities + .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); + + // Phase 5: Split by source, preserving score order + foreach (var (sourceId, orderedIds) in perSourceOrderedIds) { - result[sourceId] = items; + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } } - } - return Task.FromResult(result); + return result; + } } - private Dictionary<Guid, Dictionary<Guid, int>> ComputeBatchScores(List<Guid> sourceIds, JellyfinDbContext context) + private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken) { var result = new Dictionary<Guid, Dictionary<Guid, int>>(); foreach (var id in sourceIds) @@ -218,95 +234,52 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie result[id] = []; } - // Score item-value dimensions (genre, tags, studios) foreach (var (valueType, weight) in _itemValueDimensions) { - var sourceMap = context.ItemValuesMap.AsNoTracking() + var sourceRows = await context.ItemValuesMap.AsNoTracking() .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) - .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) - .ToList() - .GroupBy(m => m.ItemId) - .ToDictionary(g => g.Key, g => g.Select(x => x.CleanValue).ToHashSet()); + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var allValues = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allValues.Count == 0) + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) { continue; } - var valueToCandidates = context.ItemValuesMap.AsNoTracking() - .Where(m => m.ItemValue.Type == valueType && allValues.Contains(m.ItemValue.CleanValue)) - .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) - .ToList() - .GroupBy(m => m.CleanValue) - .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + var candidateRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var sourceId in sourceIds) - { - if (!sourceMap.TryGetValue(sourceId, out var sourceValues)) - { - continue; - } - - var scoreMap = result[sourceId]; - foreach (var value in sourceValues) - { - if (valueToCandidates.TryGetValue(value, out var candidates)) - { - foreach (var candidateId in candidates) - { - scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; - } - } - } - } + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } - // Score people dimensions (directors, actors) foreach (var (personTypes, weight) in _peopleDimensions) { - var sourceMap = context.PeopleBaseItemMap.AsNoTracking() + var sourceRows = await context.PeopleBaseItemMap.AsNoTracking() .Where(m => sourceIds.Contains(m.ItemId) && personTypes.Contains(m.People.PersonType)) - .Select(m => new { m.ItemId, m.PeopleId }) - .ToList() - .GroupBy(m => m.ItemId) - .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + .Select(m => new { m.ItemId, Key = m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var allPeopleIds = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allPeopleIds.Count == 0) + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) { continue; } - var personToCandidates = context.PeopleBaseItemMap.AsNoTracking() - .Where(m => allPeopleIds.Contains(m.PeopleId)) - .Select(m => new { m.ItemId, m.PeopleId }) - .ToList() - .GroupBy(m => m.PeopleId) - .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + var candidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => allKeys.Contains(m.PeopleId)) + .Select(m => new { m.ItemId, Key = m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var sourceId in sourceIds) - { - if (!sourceMap.TryGetValue(sourceId, out var sourcePeopleIds)) - { - continue; - } - - var scoreMap = result[sourceId]; - foreach (var peopleId in sourcePeopleIds) - { - if (personToCandidates.TryGetValue(peopleId, out var candidates)) - { - foreach (var candidateId in candidates) - { - scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; - } - } - } - } + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } - // Remove self-references and empty entries foreach (var sourceId in sourceIds) { var scoreMap = result[sourceId]; @@ -319,4 +292,35 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie return result; } + + private static void ApplyDimensionScores<TKey>( + List<Guid> sourceIds, + Dictionary<Guid, HashSet<TKey>> sourceMap, + Dictionary<TKey, List<Guid>> keyToCandidates, + int weight, + Dictionary<Guid, Dictionary<Guid, int>> result) + where TKey : notnull + { + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourceKeys)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var key in sourceKeys) + { + if (!keyToCandidates.TryGetValue(key, out var candidates)) + { + continue; + } + + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index fc83817015..358c170db2 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -299,12 +299,14 @@ public class SimilarItemsManager : ISimilarItemsManager var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( recentlyPlayedBaseline, RecommendationType.SimilarToRecentlyPlayed, - batchQuery).ConfigureAwait(false); + batchQuery, + cancellationToken).ConfigureAwait(false); var similarToLiked = await GetSimilarItemsRecommendationsAsync( likedBaseline, RecommendationType.SimilarToLikedItem, - batchQuery).ConfigureAwait(false); + batchQuery, + cancellationToken).ConfigureAwait(false); var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); @@ -356,7 +358,8 @@ public class SimilarItemsManager : ISimilarItemsManager private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync( IReadOnlyList<BaseItem> baselineItems, RecommendationType recommendationType, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var batchProvider = _similarItemsProviders .OfType<IBatchLocalSimilarItemsProvider>() @@ -367,7 +370,7 @@ public class SimilarItemsManager : ISimilarItemsManager return []; } - var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query).ConfigureAwait(false); + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false); var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count); foreach (var baseline in baselineItems) |
