diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..cc85f09d23 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 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..b4ed12a20c 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,36 +1,72 @@ 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; + + // Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id + // load, navigation includes) stay bounded regardless of caller input. + private const int MaxBatchSourceItems = 64; + + private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions = + [ + (ItemValueType.Genre, GenreWeight), + (ItemValueType.Tags, TagWeight), + (ItemValueType.Studios, StudioWeight) + ]; + + private static readonly Dictionary _personTypeWeights = new(StringComparer.Ordinal) + { + [nameof(PersonKind.Director)] = DirectorWeight, + [nameof(PersonKind.Actor)] = ActorWeight, + [nameof(PersonKind.GuestStar)] = ActorWeight, + }; + + private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys]; + + 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 +77,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, cancellationToken).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, cancellationToken).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } bool ILocalSimilarItemsProvider.Supports(Type itemType) @@ -63,29 +101,233 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) }; - private IReadOnlyList GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + /// + public async Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query, + CancellationToken cancellationToken) { var includeItemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { includeItemTypes.Add(BaseItemKind.Trailer); includeItemTypes.Add(BaseItemKind.LiveTvProgram); } - var internalQuery = new InternalItemsQuery(query.User) - { - Genres = item.Genres, - Tags = item.Tags, - Limit = query.Limit, - DtoOptions = query.DtoOptions ?? new DtoOptions(), - ExcludeItemIds = [.. query.ExcludeItemIds], - IncludeItemTypes = [.. includeItemTypes], - EnableGroupByMetadataKey = true, - EnableTotalRecordCount = false, - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] - }; + var limit = query.Limit ?? 50; + var dtoOptions = query.DtoOptions ?? new DtoOptions(); - return _libraryManager.GetItemList(internalQuery); + if (sourceItems.Count > MaxBatchSourceItems) + { + sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList(); + } + + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + // 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); + + 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 result; + } + + // 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; + } + + 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 result; + } + + // Phase 4: One entity load for all results + 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) + { + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } + } + + return result; + } + } + + private static async Task>> ComputeBatchScoresAsync(List sourceIds, JellyfinDbContext context, CancellationToken cancellationToken) + { + var result = new Dictionary>(); + foreach (var id in sourceIds) + { + result[id] = []; + } + + foreach (var (valueType, weight) in _itemValueDimensions) + { + var sourceRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + 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 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); + + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); + } + + var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType)) + .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + if (personSourceRows.Count > 0) + { + var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => context.PeopleBaseItemMap + .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) + .Select(s => s.PeopleId) + .Contains(m.PeopleId)) + .Select(m => new { m.ItemId, m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var personToCandidates = personCandidateRows + .GroupBy(r => r.PeopleId) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!])) + { + var sourceMap = weightGroup + .GroupBy(r => r.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result); + } + } + + foreach (var sourceId in sourceIds) + { + var scoreMap = result[sourceId]; + scoreMap.Remove(sourceId); + if (scoreMap.Count == 0) + { + result.Remove(sourceId); + } + } + + return result; + } + + private static void ApplyDimensionScores( + 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 b56779cf3f..358c170db2 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; } /// @@ -225,6 +233,211 @@ public class SimilarItemsManager : ISimilarItemsManager .ToList(); } + /// + 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, + cancellationToken).ConfigureAwait(false); + + var similarToLiked = await GetSimilarItemsRecommendationsAsync( + likedBaseline, + RecommendationType.SimilarToLikedItem, + batchQuery, + cancellationToken).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, + CancellationToken cancellationToken) + { + var batchProvider = _similarItemsProviders + .OfType() + .FirstOrDefault(); + + if (batchProvider is null || baselineItems.Count == 0) + { + return []; + } + + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).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( IReadOnlyList references, int providerOrder, diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 50d34d0656..a1f2fe7ce7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,17 +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; @@ -30,27 +26,23 @@ 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; } /// @@ -61,15 +53,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 async 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() @@ -77,251 +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) + return Ok(recommendations.Select(r => new RecommendationDto { - 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, - 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(); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); - - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); - - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); - - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); - - var categoryTypes = new List> - { - // Give this extra weight - similarToRecentlyPlayed, - similarToRecentlyPlayed, - - // Give this extra weight - similarToLiked, - similarToLiked, - hasDirectorFromRecentlyPlayed, - hasActorFromRecentlyPlayed - }; - - 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 Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); - } - - private IEnumerable GetWithDirector( - 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); - } - - 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 }, - 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 - }; - } - } - } - - private IEnumerable GetWithActor(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); - } - - 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 - }; - } - } - } - - private IEnumerable GetSimilarTo(User? user, IEnumerable baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - 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 - }; - } - } - } - - private IEnumerable GetActors(IEnumerable items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty(), new[] { PersonType.Director }) - { - MaxListOrder = 3 - }); - - 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())); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + BaselineItemName = r.BaselineItemName, + CategoryId = r.CategoryId, + RecommendationType = r.RecommendationType, + Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user) + })); } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index b612112d49..6062aaca2f 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..af49711606 --- /dev/null +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading; +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. + /// The cancellation token. + /// Per-source-item results keyed by source item ID. + Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f4c2196400..c23eba75ef 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..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; @@ -47,4 +48,23 @@ public interface ISimilarItemsManager int? limit, LibraryOptions? libraryOptions, CancellationToken cancellationToken); + + /// + /// Builds movie recommendations for a user: a mix of similar-items and person-based categories, + /// scheduled round-robin and capped to . + /// + /// 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; } +} 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); }