From 97c20e6ac5bf89aa0a29f950b9308036e589de12 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 15 May 2026 14:37:01 +0200 Subject: Fix movie recommendations --- .../Library/LibraryManager.cs | 6 + .../SimilarItems/MovieSimilarItemsProvider.cs | 274 +++++++++++++++++++-- .../Library/SimilarItems/SimilarItemsManager.cs | 17 ++ 3 files changed, 276 insertions(+), 21 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f2480679d9..c20a4442eb 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3389,6 +3389,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + { + return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit); + } + public void UpdatePeople(BaseItem item, List people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 93aa0574c0..5660cf30a8 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,36 +1,65 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; namespace Emby.Server.Implementations.Library.SimilarItems; /// -/// Provides similar items for movies and trailers. +/// Provides similar items for movies and trailers using weighted scoring. /// -public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider, IBatchLocalSimilarItemsProvider { - private readonly ILibraryManager _libraryManager; + private const int GenreWeight = 10; + private const int TagWeight = 5; + private const int StudioWeight = 5; + private const int DirectorWeight = 50; + private const int ActorWeight = 15; + + private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions = + [ + (ItemValueType.Genre, GenreWeight), + (ItemValueType.Tags, TagWeight), + (ItemValueType.Studios, StudioWeight) + ]; + + private static readonly (string[] PersonTypes, int Weight)[] _peopleDimensions = + [ + ([nameof(PersonKind.Director)], DirectorWeight), + ([nameof(PersonKind.Actor), nameof(PersonKind.GuestStar)], ActorWeight) + ]; + + private readonly IDbContextFactory _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; private readonly IServerConfigurationManager _serverConfigurationManager; /// /// Initializes a new instance of the class. /// - /// The library manager. + /// The database context factory. + /// The shared query helpers. /// The server configuration manager. public MovieSimilarItemsProvider( - ILibraryManager libraryManager, + IDbContextFactory dbProvider, + IItemQueryHelpers queryHelpers, IServerConfigurationManager serverConfigurationManager) { - _libraryManager = libraryManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; _serverConfigurationManager = serverConfigurationManager; } @@ -41,15 +70,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider MetadataPluginType.LocalSimilarityProvider; /// - public Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } /// - public Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } bool ILocalSimilarItemsProvider.Supports(Type itemType) @@ -63,29 +94,230 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) }; - private IReadOnlyList GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + /// + public Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query) { var includeItemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { includeItemTypes.Add(BaseItemKind.Trailer); includeItemTypes.Add(BaseItemKind.LiveTvProgram); } - var internalQuery = new InternalItemsQuery(query.User) + 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(); + foreach (var (_, scores) in perSourceScores) + { + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); + } + + var result = new Dictionary>(); + if (allCandidateIds.Count == 0) + { + return Task.FromResult(result); + } + + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) { - Genres = item.Genres, - Tags = item.Tags, - Limit = query.Limit, - DtoOptions = query.DtoOptions ?? new DtoOptions(), - ExcludeItemIds = [.. query.ExcludeItemIds], IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, EnableGroupByMetadataKey = true, EnableTotalRecordCount = false, - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + IsMovie = true, + IsPlayed = false }; - return _libraryManager.GetItemList(internalQuery); + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = baseQuery + .Where(e => allCandidateIdsList.Contains(e.Id)) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToList(); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet(); + var perSourceOrderedIds = new Dictionary>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } + + 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) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } + } + + 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) + .AsSplitQuery() + .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) + { + result[sourceId] = items; + } + } + + return Task.FromResult(result); + } + + private Dictionary> ComputeBatchScores(List sourceIds, JellyfinDbContext context) + { + var result = new Dictionary>(); + foreach (var id in sourceIds) + { + result[id] = []; + } + + // Score item-value dimensions (genre, tags, studios) + foreach (var (valueType, weight) in _itemValueDimensions) + { + var sourceMap = 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()); + + var allValues = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allValues.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()); + + 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; + } + } + } + } + } + + // Score people dimensions (directors, actors) + foreach (var (personTypes, weight) in _peopleDimensions) + { + var sourceMap = 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()); + + var allPeopleIds = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allPeopleIds.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()); + + 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; + } + } + } + } + } + + // Remove self-references and empty entries + foreach (var sourceId in sourceIds) + { + var scoreMap = result[sourceId]; + scoreMap.Remove(sourceId); + if (scoreMap.Count == 0) + { + result.Remove(sourceId); + } + } + + return result; } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index b56779cf3f..d4ee643f95 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -225,6 +225,23 @@ public class SimilarItemsManager : ISimilarItemsManager .ToList(); } + /// + public Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query) + { + var batchProvider = _similarItemsProviders + .OfType() + .FirstOrDefault(); + + if (batchProvider is null) + { + return Task.FromResult(new Dictionary>()); + } + + return batchProvider.GetBatchSimilarItemsAsync(sourceItems, query); + } + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( IReadOnlyList references, int providerOrder, -- cgit v1.2.3