From 97c20e6ac5bf89aa0a29f950b9308036e589de12 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 15 May 2026 14:37:01 +0200 Subject: Fix movie recommendations --- MediaBrowser.Controller/Library/ISimilarItemsManager.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'MediaBrowser.Controller/Library/ISimilarItemsManager.cs') 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); } -- 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/ISimilarItemsManager.cs') 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