From 07a802d8fa93460c9f2a7f42da7a1f14a893a322 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 23:33:56 +0200 Subject: Implement search providers --- .../Library/IExternalSearchProvider.cs | 20 ++++++++ .../Library/IInternalSearchProvider.cs | 8 +++ MediaBrowser.Controller/Library/ISearchEngine.cs | 18 ------- MediaBrowser.Controller/Library/ISearchManager.cs | 48 +++++++++++++++++ MediaBrowser.Controller/Library/ISearchProvider.cs | 44 ++++++++++++++++ .../Library/SearchProviderQuery.cs | 45 ++++++++++++++++ MediaBrowser.Controller/Library/SearchResult.cs | 60 ++++++++++++++++++++++ 7 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 MediaBrowser.Controller/Library/IExternalSearchProvider.cs create mode 100644 MediaBrowser.Controller/Library/IInternalSearchProvider.cs delete mode 100644 MediaBrowser.Controller/Library/ISearchEngine.cs create mode 100644 MediaBrowser.Controller/Library/ISearchManager.cs create mode 100644 MediaBrowser.Controller/Library/ISearchProvider.cs create mode 100644 MediaBrowser.Controller/Library/SearchProviderQuery.cs create mode 100644 MediaBrowser.Controller/Library/SearchResult.cs (limited to 'MediaBrowser.Controller/Library') diff --git a/MediaBrowser.Controller/Library/IExternalSearchProvider.cs b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs new file mode 100644 index 0000000000..bded8ba3a3 --- /dev/null +++ b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; + +namespace MediaBrowser.Controller.Library; + +/// +/// Interface for external search providers that offer enhanced search capabilities. +/// +public interface IExternalSearchProvider : ISearchProvider +{ + /// + /// Searches for items matching the query. + /// + /// The search query. + /// Cancellation token. + /// Async enumerable of search results with relevance scores. + new IAsyncEnumerable SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IInternalSearchProvider.cs b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs new file mode 100644 index 0000000000..f87931395d --- /dev/null +++ b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs @@ -0,0 +1,8 @@ +namespace MediaBrowser.Controller.Library; + +/// +/// Marker interface for internal search providers that typically query the local database directly. +/// +public interface IInternalSearchProvider : ISearchProvider +{ +} diff --git a/MediaBrowser.Controller/Library/ISearchEngine.cs b/MediaBrowser.Controller/Library/ISearchEngine.cs deleted file mode 100644 index 31dcbba5bd..0000000000 --- a/MediaBrowser.Controller/Library/ISearchEngine.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace MediaBrowser.Controller.Library -{ - /// - /// Interface ILibrarySearchEngine. - /// - public interface ISearchEngine - { - /// - /// Gets the search hints. - /// - /// The query. - /// Task{IEnumerable{SearchHintInfo}}. - QueryResult GetSearchHints(SearchQuery query); - } -} diff --git a/MediaBrowser.Controller/Library/ISearchManager.cs b/MediaBrowser.Controller/Library/ISearchManager.cs new file mode 100644 index 0000000000..4f763829a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchManager.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; + +namespace MediaBrowser.Controller.Library; + +/// +/// Orchestrates search operations across registered search providers. +/// +public interface ISearchManager +{ + /// + /// Searches for items and returns hints suitable for autocomplete/typeahead UI. + /// Results are ordered by relevance score from search providers. + /// + /// The search query including filters and pagination. + /// Cancellation token. + /// Paginated search hints with item metadata for display. + Task> GetSearchHintsAsync( + SearchQuery query, + CancellationToken cancellationToken = default); + + /// + /// Gets ranked search results from registered providers. Returns only item IDs and + /// relevance scores; callers are responsible for loading items and applying user-access filtering. + /// + /// The search provider query with type/media filters. + /// Cancellation token. + /// Search results containing item IDs and relevance scores. + Task> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default); + + /// + /// Registers search providers discovered through dependency injection. + /// Called during application startup. + /// + /// The search providers to register. + void AddParts(IEnumerable providers); + + /// + /// Gets all registered search providers ordered by priority. + /// + /// The list of search providers including the SQL fallback provider. + IReadOnlyList GetProviders(); +} diff --git a/MediaBrowser.Controller/Library/ISearchProvider.cs b/MediaBrowser.Controller/Library/ISearchProvider.cs new file mode 100644 index 0000000000..3b300ed38b --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Interface for search providers. +/// +public interface ISearchProvider +{ + /// + /// Gets the name of the provider. + /// + string Name { get; } + + /// + /// Gets the type of the provider. + /// + MetadataPluginType Type { get; } + + /// + /// Gets the priority of the provider. Lower values execute first. + /// + int Priority { get; } + + /// + /// Searches for items matching the query. + /// + /// The search query. + /// Cancellation token. + /// Ranked list of candidate item IDs with scores. + Task> SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); + + /// + /// Determines whether this provider can handle the given query. + /// + /// The search query to evaluate. + /// True if this provider can search for the query; otherwise, false. + bool CanSearch(SearchProviderQuery query); +} diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs new file mode 100644 index 0000000000..845588c872 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs @@ -0,0 +1,45 @@ +using System; +using Jellyfin.Data.Enums; + +namespace MediaBrowser.Controller.Library; + +/// +/// Query object for search providers. +/// +public class SearchProviderQuery +{ + /// + /// Gets the search term. + /// + public required string SearchTerm { get; init; } + + /// + /// Gets the user ID for user-specific searches. + /// + public Guid? UserId { get; init; } + + /// + /// Gets the item types to include in the search. + /// + public BaseItemKind[] IncludeItemTypes { get; init; } = []; + + /// + /// Gets the item types to exclude from the search. + /// + public BaseItemKind[] ExcludeItemTypes { get; init; } = []; + + /// + /// Gets the media types to include in the search. + /// + public MediaType[] MediaTypes { get; init; } = []; + + /// + /// Gets the maximum number of results to return. + /// + public int? Limit { get; init; } + + /// + /// Gets the parent ID to scope the search. + /// + public Guid? ParentId { get; init; } +} diff --git a/MediaBrowser.Controller/Library/SearchResult.cs b/MediaBrowser.Controller/Library/SearchResult.cs new file mode 100644 index 0000000000..e6f145e979 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchResult.cs @@ -0,0 +1,60 @@ +using System; + +namespace MediaBrowser.Controller.Library; + +/// +/// Represents an item matched by a search query with its relevance score. +/// +public readonly struct SearchResult : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The item ID. + /// The relevance score. + public SearchResult(Guid itemId, float score) + { + ItemId = itemId; + Score = score; + } + + /// + /// Gets the ID of the matching item. + /// + public Guid ItemId { get; init; } + + /// + /// Gets the relevance score. Higher values indicate more relevant results. + /// + public float Score { get; init; } + + /// + /// Compares two instances for equality. + /// + /// The left operand. + /// The right operand. + /// True if the instances are equal; otherwise, false. + public static bool operator ==(SearchResult left, SearchResult right) + => left.Equals(right); + + /// + /// Compares two instances for inequality. + /// + /// The left operand. + /// The right operand. + /// True if the instances are not equal; otherwise, false. + public static bool operator !=(SearchResult left, SearchResult right) + => !left.Equals(right); + + /// + public override bool Equals(object? obj) + => obj is SearchResult other && Equals(other); + + /// + public bool Equals(SearchResult other) + => ItemId.Equals(other.ItemId) && Score.Equals(other.Score); + + /// + public override int GetHashCode() + => HashCode.Combine(ItemId, Score); +} -- cgit v1.2.3 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 ++ Jellyfin.Api/Controllers/MoviesController.cs | 248 +++++++++---------- .../Item/PeopleRepository.cs | 27 +- .../Library/IBatchLocalSimilarItemsProvider.cs | 23 ++ MediaBrowser.Controller/Library/ILibraryManager.cs | 9 + .../Library/ISimilarItemsManager.cs | 10 + .../Persistence/IPeopleRepository.cs | 9 + 9 files changed, 475 insertions(+), 148 deletions(-) create mode 100644 MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs (limited to 'MediaBrowser.Controller/Library') 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, diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 50d34d0656..9c0e216f12 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -2,6 +2,9 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -33,6 +36,7 @@ public class MoviesController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISimilarItemsManager _similarItemsManager; /// /// Initializes a new instance of the class. @@ -41,16 +45,19 @@ public class MoviesController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public MoviesController( IUserManager userManager, ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + ISimilarItemsManager similarItemsManager) { _userManager = userManager; _libraryManager = libraryManager; _dtoService = dtoService; _serverConfigurationManager = serverConfigurationManager; + _similarItemsManager = similarItemsManager; } /// @@ -61,15 +68,17 @@ public class MoviesController : BaseJellyfinApiController /// Optional. The fields to return. /// The max number of categories to return. /// The max number of items to return per category. + /// The cancellation token. /// Movie recommendations returned. /// The list of movie recommendations. [HttpGet("Recommendations")] - public ActionResult> GetMovieRecommendations( + public Task>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) + [FromQuery] int itemLimit = 8, + CancellationToken cancellationToken = default) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -86,15 +95,13 @@ public class MoviesController : BaseJellyfinApiController IncludeItemTypes = new[] { BaseItemKind.Movie, - // nameof(Trailer), - // nameof(LiveTvProgram) }, - // IsMovie = true OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, Limit = 7, ParentId = parentIdGuid, Recursive = true, IsPlayed = true, + EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }; @@ -122,31 +129,52 @@ public class MoviesController : BaseJellyfinApiController }); var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); + var recentDirectors = GetDirectors(mostRecentMovies).ToList(); + var recentActors = GetActors(mostRecentMovies).ToList(); + + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; + + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; + + var similarToRecentlyPlayed = BuildPendingFromBatch( + _similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery), + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed); - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); + var similarToLiked = BuildPendingFromBatch( + _similarItemsManager.GetBatchSimilarItemsAsync(likedBaseline, batchQuery), + likedBaseline, + RecommendationType.SimilarToLikedItem); - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); + var hasDirectorFromRecentlyPlayed = GetWithPerson(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed); + var hasActorFromRecentlyPlayed = GetWithPerson(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed); - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); - var categoryTypes = new List> + var categoryTypes = new List> { - // Give this extra weight - similarToRecentlyPlayed, - similarToRecentlyPlayed, - - // Give this extra weight - similarToLiked, - similarToLiked, - hasDirectorFromRecentlyPlayed, - hasActorFromRecentlyPlayed + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() }; while (categories.Count < categoryLimit) @@ -157,7 +185,17 @@ public class MoviesController : BaseJellyfinApiController { if (category.MoveNext()) { - categories.Add(category.Current); + var pending = category.Current; + var returnItems = _dtoService.GetBaseItemDtos(pending.Items, dtoOptions, user); + + categories.Add(new RecommendationDto + { + BaselineItemName = pending.BaselineItemName, + CategoryId = pending.CategoryId, + RecommendationType = pending.RecommendationType, + Items = returnItems + }); + allEmpty = false; if (categories.Count >= categoryLimit) @@ -173,10 +211,36 @@ public class MoviesController : BaseJellyfinApiController } } - return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); + return Task.FromResult>>( + Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable())); + } + + private static List BuildPendingFromBatch( + Task>> batchTask, + IReadOnlyList baselineItems, + RecommendationType type) + { + var batchResults = batchTask.GetAwaiter().GetResult(); + var results = new List(); + + foreach (var item in baselineItems) + { + if (batchResults.TryGetValue(item.Id, out var similar) && similar.Count > 0) + { + results.Add(new PendingRecommendation + { + BaselineItemName = item.Name, + CategoryId = item.Id, + RecommendationType = type, + Items = similar + }); + } + } + + return results; } - private IEnumerable GetWithDirector( + private IEnumerable GetWithPerson( User? user, IEnumerable names, int itemLimit, @@ -190,17 +254,21 @@ public class MoviesController : BaseJellyfinApiController itemTypes.Add(BaseItemKind.LiveTvProgram); } + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty(); + foreach (var name in names) { var items = _libraryManager.GetItemList( new InternalItemsQuery(user) { Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, + PersonTypes = personTypes, IncludeItemTypes = itemTypes.ToArray(), IsMovie = true, + IsPlayed = false, EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) @@ -209,119 +277,47 @@ public class MoviesController : BaseJellyfinApiController if (items.Count > 0) { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto + yield return new PendingRecommendation { BaselineItemName = name, CategoryId = name.GetMD5(), RecommendationType = type, - Items = returnItems + Items = items }; } } } - private IEnumerable GetWithActor(User? user, IEnumerable names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + private IReadOnlyList GetActors(IReadOnlyList items) { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems( + itemIds, + new[] { PersonType.Actor, PersonType.GuestStar }, + limit: 0); } - private IEnumerable GetSimilarTo(User? user, IEnumerable baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + private IReadOnlyList GetDirectors(IReadOnlyList items) { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var item in baselineItems) - { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); - - if (similar.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } - } + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems( + itemIds, + [PersonType.Director], + limit: 0); } - private IEnumerable GetActors(IEnumerable items) + /// + /// Holds a recommendation category's BaseItems before DTO conversion. + /// DTO conversion is deferred until the round-robin actually selects the category. + /// + private sealed class PendingRecommendation { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty(), new[] { PersonType.Director }) - { - MaxListOrder = 3 - }); + public required string BaselineItemName { get; init; } - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } - - private IEnumerable GetDirectors(IEnumerable items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty())); + public required Guid CategoryId { get; init; } - var itemIds = items.Select(i => i.Id).ToList(); + public required RecommendationType RecommendationType { get; init; } - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + public required IReadOnlyList Items { get; init; } } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 8f8741d00f..88bf6fa1bb 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -165,6 +165,31 @@ public class PeopleRepository(IDbContextFactory dbProvider, I transaction.Commit(); } + /// + public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + { + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + var names = query + .Select(m => m.People.Name) + .Distinct(); + + if (limit > 0) + { + names = names.Take(limit); + } + + return names.ToArray(); + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); @@ -239,7 +264,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value); + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value)); } if (!string.IsNullOrWhiteSpace(filter.NameContains)) diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..fe2ce7d394 --- /dev/null +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// A local similar items provider that supports batch queries across multiple source items. +/// Implementations share access filtering and entity loading across all sources for better performance. +/// +public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// + /// Gets similar items for multiple source items in a single batch. + /// + /// The source items to find similar items for. + /// The query options. + /// Per-source-item results keyed by source item ID. + Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query); +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f5e3d7034e..365f078652 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -597,6 +597,15 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Gets distinct people names for multiple items. + /// + /// The item IDs. + /// The person types to include. + /// Maximum number of names. + /// The distinct people names. + IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); + /// /// Queries the items. /// diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs index 0ced6f71ee..1c826ea780 100644 --- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -47,4 +47,14 @@ public interface ISimilarItemsManager int? limit, LibraryOptions? libraryOptions, CancellationToken cancellationToken); + + /// + /// Gets similar items for multiple source items in a single batch. + /// + /// The source items to find similar items for. + /// The query options. + /// Per-source-item results keyed by source item ID. + Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query); } diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..7474130ec4 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,4 +32,13 @@ public interface IPeopleRepository /// The query. /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + + /// + /// Gets distinct people names for multiple items efficiently by querying from the mapping table. + /// + /// The item IDs to get people for. + /// The person types to include (e.g. "Actor", "Director"). + /// Maximum number of names to return. + /// The distinct people names. + IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); } -- cgit v1.2.3 From 1fdf58e40f7c8f58377be3716368720923d8d8c0 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 09:44:36 +0200 Subject: Address review comments --- .../SimilarItems/MovieSimilarItemsProvider.cs | 1 - .../Library/SimilarItems/SimilarItemsManager.cs | 205 +++++++++++++++- Jellyfin.Api/Controllers/MoviesController.cs | 257 +-------------------- .../Library/ISimilarItemsManager.cs | 24 +- .../Library/SimilarItemsRecommendation.cs | 32 +++ 5 files changed, 258 insertions(+), 261 deletions(-) create mode 100644 MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs (limited to 'MediaBrowser.Controller/Library') diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 5660cf30a8..54466a6ad9 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -188,7 +188,6 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider allOrderedIdsList.Contains(e.Id)), filter) - .AsSplitQuery() .AsEnumerable() .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) .Where(dto => dto is not null) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index d4ee643f95..fc83817015 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -8,12 +8,16 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; @@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; private ISimilarItemsProvider[] _similarItemsProviders = []; /// @@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager /// The server application paths. /// The library manager. /// The file system. + /// The server configuration manager. public SimilarItemsManager( ILogger logger, IServerApplicationPaths appPaths, ILibraryManager libraryManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager) { _logger = logger; _appPaths = appPaths; _libraryManager = libraryManager; _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; } /// @@ -226,20 +234,205 @@ public class SimilarItemsManager : ISimilarItemsManager } /// - public Task>> GetBatchSimilarItemsAsync( - IReadOnlyList sourceItems, + public async Task> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dtoOptions); + + var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = [BaseItemKind.Movie], + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)], + Limit = 7, + ParentId = parentId, + Recursive = true, + IsPlayed = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }); + + var itemTypes = new List { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Descending)], + Limit = 10, + IsFavoriteOrLiked = true, + ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), + EnableGroupByMetadataKey = true, + ParentId = parentId, + Recursive = true, + DtoOptions = dtoOptions + }); + + var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); + var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]); + var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]); + + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; + + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; + + var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed, + batchQuery).ConfigureAwait(false); + + var similarToLiked = await GetSimilarItemsRecommendationsAsync( + likedBaseline, + RecommendationType.SimilarToLikedItem, + batchQuery).ConfigureAwait(false); + + var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); + var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); + + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); + + var categoryTypes = new List> + { + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() + }; + + var categories = new List(); + while (categories.Count < categoryLimit) + { + var allEmpty = true; + foreach (var category in categoryTypes) + { + if (category.MoveNext()) + { + categories.Add(category.Current); + allEmpty = false; + + if (categories.Count >= categoryLimit) + { + break; + } + } + } + + if (allEmpty) + { + break; + } + } + + return [.. categories.OrderBy(i => i.RecommendationType)]; + } + + private async Task> GetSimilarItemsRecommendationsAsync( + IReadOnlyList baselineItems, + RecommendationType recommendationType, SimilarItemsQuery query) { var batchProvider = _similarItemsProviders .OfType() .FirstOrDefault(); - if (batchProvider is null) + if (batchProvider is null || baselineItems.Count == 0) { - return Task.FromResult(new Dictionary>()); + return []; } - return batchProvider.GetBatchSimilarItemsAsync(sourceItems, query); + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query).ConfigureAwait(false); + + var recommendations = new List(baselineItems.Count); + foreach (var baseline in baselineItems) + { + if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0) + { + recommendations.Add(new SimilarItemsRecommendation + { + BaselineItemName = baseline.Name, + CategoryId = baseline.Id, + RecommendationType = recommendationType, + Items = similar + }); + } + } + + return recommendations; + } + + private IEnumerable GetPersonRecommendations( + User? user, + IReadOnlyList names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type, + IReadOnlyList itemTypes) + { + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty(); + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + Limit = itemLimit + 2, + PersonTypes = personTypes, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + IsPlayed = false, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }) + .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + yield return new SimilarItemsRecommendation + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = items + }; + } + } + } + + private IReadOnlyList GetPeopleNames(IReadOnlyList items, IReadOnlyList personTypes) + { + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0); } private List<(BaseItem Item, float Score)> ResolveRemoteReferences( diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 9c0e216f12..a1f2fe7ce7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,20 +1,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -33,30 +26,22 @@ namespace Jellyfin.Api.Controllers; public class MoviesController : BaseJellyfinApiController { private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ISimilarItemsManager _similarItemsManager; /// /// Initializes a new instance of the class. /// /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. public MoviesController( IUserManager userManager, - ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager, ISimilarItemsManager similarItemsManager) { _userManager = userManager; - _libraryManager = libraryManager; _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; _similarItemsManager = similarItemsManager; } @@ -72,7 +57,7 @@ public class MoviesController : BaseJellyfinApiController /// Movie recommendations returned. /// The list of movie recommendations. [HttpGet("Recommendations")] - public Task>> GetMovieRecommendations( + public async Task>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, @@ -86,238 +71,16 @@ public class MoviesController : BaseJellyfinApiController : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields }; - var categories = new List(); + var recommendations = await _similarItemsManager + .GetMovieRecommendationsAsync(user, parentId ?? Guid.Empty, categoryLimit, itemLimit, dtoOptions, cancellationToken) + .ConfigureAwait(false); - var parentIdGuid = parentId ?? Guid.Empty; - - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] - { - BaseItemKind.Movie, - }, - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }; - - var recentlyPlayedMovies = _libraryManager.GetItemList(query); - - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - var recentDirectors = GetDirectors(mostRecentMovies).ToList(); - var recentActors = GetActors(mostRecentMovies).ToList(); - - // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. - var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit - ? recentlyPlayedMovies.Take(categoryLimit).ToList() - : recentlyPlayedMovies; - var likedBaseline = likedMovies.Count > categoryLimit - ? likedMovies.Take(categoryLimit).ToList() - : likedMovies; - - var batchQuery = new SimilarItemsQuery - { - User = user, - Limit = itemLimit, - DtoOptions = dtoOptions - }; - - var similarToRecentlyPlayed = BuildPendingFromBatch( - _similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery), - recentlyPlayedBaseline, - RecommendationType.SimilarToRecentlyPlayed); - - var similarToLiked = BuildPendingFromBatch( - _similarItemsManager.GetBatchSimilarItemsAsync(likedBaseline, batchQuery), - likedBaseline, - RecommendationType.SimilarToLikedItem); - - var hasDirectorFromRecentlyPlayed = GetWithPerson(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed); - var hasActorFromRecentlyPlayed = GetWithPerson(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed); - - // Use a single enumerator per list, listed twice so MoveNext advances it - // twice per round-robin pass (giving these categories double weight). - // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; - // using var would box separately per list insertion, creating independent copies. - IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); - IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); - - var categoryTypes = new List> - { - similarToRecentlyPlayedEnum, - similarToRecentlyPlayedEnum, - similarToLikedEnum, - similarToLikedEnum, - hasDirectorFromRecentlyPlayed.GetEnumerator(), - hasActorFromRecentlyPlayed.GetEnumerator() - }; - - while (categories.Count < categoryLimit) + return Ok(recommendations.Select(r => new RecommendationDto { - var allEmpty = true; - - foreach (var category in categoryTypes) - { - if (category.MoveNext()) - { - var pending = category.Current; - var returnItems = _dtoService.GetBaseItemDtos(pending.Items, dtoOptions, user); - - categories.Add(new RecommendationDto - { - BaselineItemName = pending.BaselineItemName, - CategoryId = pending.CategoryId, - RecommendationType = pending.RecommendationType, - Items = returnItems - }); - - allEmpty = false; - - if (categories.Count >= categoryLimit) - { - break; - } - } - } - - if (allEmpty) - { - break; - } - } - - return Task.FromResult>>( - Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable())); - } - - private static List BuildPendingFromBatch( - Task>> batchTask, - IReadOnlyList baselineItems, - RecommendationType type) - { - var batchResults = batchTask.GetAwaiter().GetResult(); - var results = new List(); - - foreach (var item in baselineItems) - { - if (batchResults.TryGetValue(item.Id, out var similar) && similar.Count > 0) - { - results.Add(new PendingRecommendation - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = similar - }); - } - } - - return results; - } - - private IEnumerable GetWithPerson( - User? user, - IEnumerable names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed - ? [PersonType.Director] - : Array.Empty(); - - foreach (var name in names) - { - var items = _libraryManager.GetItemList( - new InternalItemsQuery(user) - { - Person = name, - Limit = itemLimit + 2, - PersonTypes = personTypes, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - IsPlayed = false, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - yield return new PendingRecommendation - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = items - }; - } - } - } - - private IReadOnlyList GetActors(IReadOnlyList items) - { - var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems( - itemIds, - new[] { PersonType.Actor, PersonType.GuestStar }, - limit: 0); - } - - private IReadOnlyList GetDirectors(IReadOnlyList items) - { - var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems( - itemIds, - [PersonType.Director], - limit: 0); - } - - /// - /// Holds a recommendation category's BaseItems before DTO conversion. - /// DTO conversion is deferred until the round-robin actually selects the category. - /// - private sealed class PendingRecommendation - { - public required string BaselineItemName { get; init; } - - public required Guid CategoryId { get; init; } - - public required RecommendationType RecommendationType { get; init; } - - public required IReadOnlyList Items { get; init; } + BaselineItemName = r.BaselineItemName, + CategoryId = r.CategoryId, + RecommendationType = r.RecommendationType, + Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user) + })); } } diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs index 1c826ea780..36fa547eeb 100644 --- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -6,6 +6,7 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.Library; @@ -49,12 +50,21 @@ public interface ISimilarItemsManager CancellationToken cancellationToken); /// - /// Gets similar items for multiple source items in a single batch. + /// Builds movie recommendations for a user: a mix of similar-items and person-based categories, + /// scheduled round-robin and capped to . /// - /// The source items to find similar items for. - /// The query options. - /// Per-source-item results keyed by source item ID. - Task>> GetBatchSimilarItemsAsync( - IReadOnlyList sourceItems, - SimilarItemsQuery query); + /// The user the recommendations are for. May be for anonymous access. + /// The library/folder to localize the search to. Pass to use the root. + /// Maximum number of recommendation categories to return. + /// Maximum number of items per category. + /// DTO options used when querying the library. + /// The cancellation token. + /// The list of recommendation categories, ordered by . + Task> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs new file mode 100644 index 0000000000..71346fcadf --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library; + +/// +/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion. +/// +public sealed class SimilarItemsRecommendation +{ + /// + /// Gets the display name of the baseline item the recommendation is based on. + /// + public required string BaselineItemName { get; init; } + + /// + /// Gets an identifier for the recommendation category. + /// + public required Guid CategoryId { get; init; } + + /// + /// Gets the recommendation type. + /// + public required RecommendationType RecommendationType { get; init; } + + /// + /// Gets the similar items for the baseline, ordered by relevance. + /// + public required IReadOnlyList Items { get; init; } +} -- cgit v1.2.3 From 3655b4b09449e572826fa2f91a88f3b6dd4e63c4 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 16:11:13 +0200 Subject: Apply review and sonar suggestions --- .../SimilarItems/MovieSimilarItemsProvider.cs | 312 +++++++++++---------- .../Library/SimilarItems/SimilarItemsManager.cs | 11 +- .../Library/IBatchLocalSimilarItemsProvider.cs | 5 +- 3 files changed, 169 insertions(+), 159 deletions(-) (limited to 'MediaBrowser.Controller/Library') 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 public async Task> 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 : []; } /// public async Task> 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 - public Task>> GetBatchSimilarItemsAsync( + public async Task>> GetBatchSimilarItemsAsync( IReadOnlyList sourceItems, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var includeItemTypes = new List { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) @@ -109,108 +114,119 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider i.Id).ToList(); - var perSourceScores = ComputeBatchScores(sourceIds, context); - - var allCandidateIds = new HashSet(); - 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>(); - 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(); + 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>(); + 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(); + var perSourceOrderedIds = new Dictionary>(); + + 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(); - var perSourceOrderedIds = new Dictionary>(); + 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> ComputeBatchScores(List sourceIds, JellyfinDbContext context) + private static async Task>> ComputeBatchScoresAsync(List sourceIds, JellyfinDbContext context, CancellationToken cancellationToken) { var result = new Dictionary>(); foreach (var id in sourceIds) @@ -218,95 +234,52 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider 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( + List sourceIds, + Dictionary> sourceMap, + Dictionary> keyToCandidates, + int weight, + Dictionary> 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> GetSimilarItemsRecommendationsAsync( IReadOnlyList baselineItems, RecommendationType recommendationType, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var batchProvider = _similarItemsProviders .OfType() @@ -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(baselineItems.Count); foreach (var baseline in baselineItems) diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs index fe2ce7d394..af49711606 100644 --- a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -16,8 +17,10 @@ public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider /// /// The source items to find similar items for. /// The query options. + /// The cancellation token. /// Per-source-item results keyed by source item ID. Task>> GetBatchSimilarItemsAsync( IReadOnlyList sourceItems, - SimilarItemsQuery query); + SimilarItemsQuery query, + CancellationToken cancellationToken); } -- cgit v1.2.3 From cb9d6e9884d3b952321736392801743198b0ccd9 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 24 May 2026 18:26:21 +0200 Subject: Add batch method for people names --- .../Library/LibraryManager.cs | 6 +++ .../Item/PeopleRepository.cs | 58 ++++++++++++++++++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 8 +++ .../Persistence/IPeopleRepository.cs | 10 ++++ 4 files changed, 82 insertions(+) (limited to 'MediaBrowser.Controller/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..662e28ec1d 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3394,6 +3394,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes) + { + return _peopleRepository.GetPeopleNamesByItem(itemIds, personTypes); + } + public void UpdatePeople(BaseItem item, List people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index b612112d49..d84a59850d 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -165,6 +165,64 @@ public class PeopleRepository(IDbContextFactory dbProvider, I transaction.Commit(); } + /// + public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes) + { + if (itemIds.Count == 0) + { + return new Dictionary>(); + } + + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + // One round-trip: pull (ItemId, ListOrder, Name) sorted by ItemId+ListOrder, group in memory. + var rows = query + .OrderBy(m => m.ItemId) + .ThenBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToArray(); + + var result = new Dictionary>(); + List? current = null; + var currentId = Guid.Empty; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + if (row.ItemId != currentId) + { + if (current is { Count: > 0 }) + { + result[currentId] = current; + } + + currentId = row.ItemId; + current = new List(); + seen.Clear(); + } + + if (!string.IsNullOrWhiteSpace(row.Name) && seen.Add(row.Name)) + { + current!.Add(row.Name); + } + } + + if (current is { Count: > 0 }) + { + result[currentId] = current; + } + + return result; + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f4c2196400..d794205f00 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -597,6 +597,14 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Gets the people names per item for a batch of item IDs in a single DB round-trip. + /// + /// The item IDs to look up. + /// Optional person types to include. Empty for all. + /// Dictionary keyed by item id; values are the per-item people names. Items with no people are absent. + IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes); + /// /// Queries the items. /// diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..3a3b2bfb1f 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,4 +32,14 @@ public interface IPeopleRepository /// The query. /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + + /// + /// Gets the people names per item for a batch of item IDs, preserving per-item list order. + /// One database round-trip for the whole batch; grouped by item id in memory. + /// Items with no people are omitted from the returned dictionary. + /// + /// The item IDs to get people for. + /// Optional person types to include (e.g. "Actor", "Director"). Empty for all. + /// Dictionary keyed by item id; values are the per-item people names. + IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes); } -- cgit v1.2.3 From 63d1af5fe7ed67d0e2e56c79ef518a7a87da782f Mon Sep 17 00:00:00 2001 From: Tim Eisele Date: Sun, 31 May 2026 17:21:15 +0200 Subject: Fix similarity (#16942) Fix similarity --- .../Library/LibraryManager.cs | 4 +- .../Library/SimilarItems/SimilarItemsManager.cs | 43 ++++++++++++++++------ .../Item/PeopleRepository.cs | 25 +++++++++---- MediaBrowser.Controller/Library/ILibraryManager.cs | 7 ++-- .../Persistence/IPeopleRepository.cs | 7 ++-- 5 files changed, 57 insertions(+), 29 deletions(-) (limited to 'MediaBrowser.Controller/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index cc85f09d23..a826db090f 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3395,9 +3395,9 @@ namespace Emby.Server.Implementations.Library } /// - public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { - return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit); + return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes); } public void UpdatePeople(BaseItem item, List people) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index 358c170db2..d923cff07e 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -125,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager var allResults = new List<(BaseItem Item, float Score)>(); var excludeIds = new HashSet { item.Id }; + var excludeKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() }; foreach (var (providerOrder, provider) in orderedProviders.Index()) { if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested) @@ -149,7 +150,9 @@ public class SimilarItemsManager : ISimilarItemsManager foreach (var (position, resultItem) in items.Index()) { - if (excludeIds.Add(resultItem.Id)) + var isNewId = excludeIds.Add(resultItem.Id); + var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey()); + if (isNewId && isNewKey) { var score = CalculateScore(null, providerOrder, position); allResults.Add((resultItem, score)); @@ -163,7 +166,7 @@ public class SimilarItemsManager : ISimilarItemsManager var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); if (cachedReferences is not null) { - var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); continue; } @@ -191,7 +194,7 @@ public class SimilarItemsManager : ISimilarItemsManager if (pendingBatch.Count >= BatchSize) { - var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); remaining -= resolvedItems.Count; pendingBatch.Clear(); @@ -206,7 +209,7 @@ public class SimilarItemsManager : ISimilarItemsManager // Resolve any remaining references in the last partial batch if (pendingBatch.Count > 0) { - var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); } @@ -435,7 +438,11 @@ public class SimilarItemsManager : ISimilarItemsManager private IReadOnlyList GetPeopleNames(IReadOnlyList items, IReadOnlyList personTypes) { var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0); + return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes) + .Values + .SelectMany(names => names) + .Distinct() + .ToArray(); } private List<(BaseItem Item, float Score)> ResolveRemoteReferences( @@ -444,14 +451,15 @@ public class SimilarItemsManager : ISimilarItemsManager User? user, DtoOptions dtoOptions, BaseItemKind itemKind, - HashSet excludeIds) + HashSet excludeIds, + HashSet excludeKeys) { if (references.Count == 0) { return []; } - var resolvedById = new Dictionary(); + var resolvedByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance); foreach (var (position, match) in references.Index()) @@ -482,7 +490,13 @@ public class SimilarItemsManager : ISimilarItemsManager foreach (var item in items) { - if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id)) + if (excludeIds.Contains(item.Id)) + { + continue; + } + + var presentationKey = item.GetPresentationUniqueKey(); + if (excludeKeys.Contains(presentationKey)) { continue; } @@ -492,10 +506,9 @@ public class SimilarItemsManager : ISimilarItemsManager if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) { var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); - if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score) + if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score) { - excludeIds.Add(item.Id); - resolvedById[item.Id] = (item, score); + resolvedByKey[presentationKey] = (item, score); } break; @@ -503,7 +516,13 @@ public class SimilarItemsManager : ISimilarItemsManager } } - return [.. resolvedById.Values]; + foreach (var (key, entry) in resolvedByKey) + { + excludeIds.Add(entry.Item.Id); + excludeKeys.Add(key); + } + + return [.. resolvedByKey.Values]; } private static float CalculateScore(float? matchScore, int providerOrder, int position) diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 6062aaca2f..eb87b525fe 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -166,7 +166,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I } /// - public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { using var context = _dbProvider.CreateDbContext(); var query = context.PeopleBaseItemMap @@ -178,16 +178,27 @@ public class PeopleRepository(IDbContextFactory dbProvider, I query = query.Where(m => personTypes.Contains(m.People.PersonType)); } - var names = query - .Select(m => m.People.Name) - .Distinct(); + var rows = query + .OrderBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToList(); - if (limit > 0) + var result = new Dictionary>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) { - names = names.Take(limit); + var names = group + .Select(r => r.Name) + .Where(name => !string.IsNullOrEmpty(name)) + .Distinct() + .ToArray(); + + if (names.Length > 0) + { + result[group.Key] = names; + } } - return names.ToArray(); + return result; } private PersonInfo Map(People people) diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index c23eba75ef..0b64da291c 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -598,13 +598,12 @@ namespace MediaBrowser.Controller.Library IReadOnlyList GetPeopleNames(InternalPeopleQuery query); /// - /// Gets distinct people names for multiple items. + /// Gets the distinct people names per item for multiple items. /// /// The item IDs. /// The person types to include. - /// Maximum number of names. - /// The distinct people names. - IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); + /// A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted. + IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes); /// /// Queries the items. diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index 7474130ec4..e2833dc722 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -34,11 +34,10 @@ public interface IPeopleRepository IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); /// - /// Gets distinct people names for multiple items efficiently by querying from the mapping table. + /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table. /// /// The item IDs to get people for. /// The person types to include (e.g. "Actor", "Director"). - /// Maximum number of names to return. - /// The distinct people names. - IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); + /// A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted. + IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes); } -- cgit v1.2.3